From 3fb4eefaf7ec945955303c5243eb8c7b83eb2fc6 Mon Sep 17 00:00:00 2001 From: Jakub Domeracki Date: Fri, 29 May 2026 10:23:00 +0200 Subject: [PATCH 01/14] docs(docs/admin/security): point security advisories to GitHub Security Advisories (#25813) Removes the inline security advisory table and the standalone advisory file (`0001_user_apikeys_invalidation.md`). The advisories section now directs readers to [GitHub Security Advisories](https://github.com/coder/coder/security/advisories). > Generated by Coder Agents on behalf of @jdomeracki-coder --- .../0001_user_apikeys_invalidation.md | 89 ------------------- docs/admin/security/index.md | 17 +--- 2 files changed, 3 insertions(+), 103 deletions(-) delete mode 100644 docs/admin/security/0001_user_apikeys_invalidation.md diff --git a/docs/admin/security/0001_user_apikeys_invalidation.md b/docs/admin/security/0001_user_apikeys_invalidation.md deleted file mode 100644 index 203a891766..0000000000 --- a/docs/admin/security/0001_user_apikeys_invalidation.md +++ /dev/null @@ -1,89 +0,0 @@ -# API Tokens of deleted users not invalidated - ---- - -## Summary - -Coder identified an issue in -[https://github.com/coder/coder](https://github.com/coder/coder) where API -tokens belonging to a deleted user were not invalidated. A deleted user in -possession of a valid and non-expired API token is still able to use the above -token with their full suite of capabilities. - -## Impact: HIGH - -If exploited, an attacker could perform any action that the deleted user was -authorized to perform. - -## Exploitability: HIGH - -The CLI writes the API key to `~/.coderv2/session` by default, so any deleted -user who previously logged in via the Coder CLI has the potential to exploit -this. Note that there is a time window for exploitation; API tokens have a -maximum lifetime after which they are no longer valid. - -The issue only affects users who were active (not suspended) at the time they -were deleted. Users who were first suspended and later deleted cannot exploit -this issue. - -## Affected Versions - -All versions of Coder between v0.8.15 and v0.22.2 (inclusive) are affected. - -All customers are advised to upgrade to -[v0.23.0](https://github.com/coder/coder/releases/tag/v0.23.0) as soon as -possible. - -## Details - -Coder incorrectly failed to invalidate API keys belonging to a user when they -were deleted. When authenticating a user via their API key, Coder incorrectly -failed to check whether the API key corresponds to a deleted user. - -## Indications of Compromise - -> [!TIP] -> Automated remediation steps in the upgrade purge all affected API keys. -> Either perform the following query before upgrade or run it on a backup of -> your database from before the upgrade. - -Execute the following SQL query: - -```sql -SELECT - users.email, - users.updated_at, - api_keys.id, - api_keys.last_used -FROM - users -LEFT JOIN - api_keys -ON - api_keys.user_id = users.id -WHERE - users.deleted -AND - api_keys.last_used > users.updated_at -; -``` - -If the output is similar to the below, then you are not affected: - -```sql ------ -(0 rows) -``` - -Otherwise, the following information will be reported: - -- User email -- Time the user was last modified (i.e. deleted) -- User API key ID -- Time the affected API key was last used - -> [!TIP] -> If your license includes the -> [Audit Logs](https://coder.com/docs/admin/audit-logs#filtering-logs) feature, -> you can then query all actions performed by the above users by using the -> filter `email:$USER_EMAIL`. diff --git a/docs/admin/security/index.md b/docs/admin/security/index.md index 37028093f8..f6684519e8 100644 --- a/docs/admin/security/index.md +++ b/docs/admin/security/index.md @@ -11,17 +11,6 @@ For other security tips, visit our guide to > If you discover a vulnerability in Coder, please do not hesitate to report it > to us by following the [security policy](https://github.com/coder/coder/blob/main/SECURITY.md). -From time to time, Coder employees or other community members may discover -vulnerabilities in the product. - -If a vulnerability requires an immediate upgrade to mitigate a potential -security risk, we will add it to the below table. - -Click on the description links to view more details about each specific -vulnerability. - ---- - -| Description | Severity | Fix | Vulnerable Versions | -|-----------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------------------------|---------------------| -| [API tokens of deleted users not invalidated](https://github.com/coder/coder/blob/main/docs/admin/security/0001_user_apikeys_invalidation.md) | HIGH | [v0.23.0](https://github.com/coder/coder/releases/tag/v0.23.0) | v0.8.25 - v0.22.2 | +Security advisories are published on the +[GitHub Security Advisories](https://github.com/coder/coder/security/advisories) +page. From 5b10268827e8c3a8ac8e0ad563d042e66c7003c7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 29 May 2026 10:24:16 +0200 Subject: [PATCH 02/14] feat: serve 503 sentinel for disabled providers (#25794) _Disclosure: created with Coder Agents._ When providers are disabled, we should serve a sentinel error so the requesting client (Claude Code, Coder Agents, etc) is informed. Coder Agents can also conditionalize its display to show a helpful error message. --------- Signed-off-by: Danny Kopping Co-authored-by: Claude Opus 4.7 (1M context) --- aibridge/api.go | 8 +++ aibridge/bridge.go | 27 +++++++++ aibridge/bridge_test.go | 55 +++++++++++++++++ aibridge/internal/testutil/mockprovider.go | 2 + aibridge/provider/anthropic.go | 2 + aibridge/provider/copilot.go | 2 + aibridge/provider/disabled.go | 47 +++++++++++++++ aibridge/provider/openai.go | 2 + aibridge/provider/provider.go | 2 + cli/aibridged.go | 49 +++++++++++---- cli/aibridged_internal_test.go | 69 ++++++++++++++++------ cli/server_aibridge_internal_test.go | 10 ++++ coderd/aibridged/pool.go | 7 ++- coderd/aibridged/provider.go | 6 +- enterprise/coderd/aibridge_reload_test.go | 18 +++++- 15 files changed, 269 insertions(+), 37 deletions(-) create mode 100644 aibridge/provider/disabled.go diff --git a/aibridge/api.go b/aibridge/api.go index 809d452fe9..34dce84ef8 100644 --- a/aibridge/api.go +++ b/aibridge/api.go @@ -57,6 +57,14 @@ func NewCopilotProvider(cfg config.Copilot) provider.Provider { return provider.NewCopilot(cfg) } +// NewDisabledProviderStub returns a Provider that reports Enabled() == +// false and has no-op implementations for all other methods. Use this +// instead of constructing a concrete provider for disabled rows so that +// adding a new provider type does not require updating a switch here. +func NewDisabledProviderStub(name, providerType string) provider.Provider { + return provider.NewDisabledStub(name, providerType) +} + func NewMetrics(reg prometheus.Registerer) *metrics.Metrics { return metrics.NewMetrics(reg) } diff --git a/aibridge/bridge.go b/aibridge/bridge.go index f604d0a38a..79715af880 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -30,6 +30,11 @@ import ( const ( // The duration after which an async recording will be aborted. recordingTimeout = time.Second * 5 + + // ErrorCodeProviderDisabled is the code written in the response + // body when a request targets a configured-but-disabled provider. + // Paired with HTTP 503. + ErrorCodeProviderDisabled = "provider_disabled" ) // RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs; @@ -96,6 +101,14 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re mux := http.NewServeMux() for _, prov := range providers { + // Disabled providers serve a 503 sentinel on every path under + // "//". Bound to the bare name (not RoutePrefix) so paths + // outside the provider's normal "/v1" subtree are also caught. + if !prov.Enabled() { + prefix := fmt.Sprintf("/%s/", prov.Name()) + mux.HandleFunc(prefix, disabledProviderHandler(prov.Name(), logger)) + continue + } // Create per-provider circuit breaker if configured cfg := prov.CircuitBreakerConfig() providerName := prov.Name() @@ -170,6 +183,20 @@ func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec re }, nil } +// disabledProviderHandler returns 503 with a body containing +// [ErrorCodeProviderDisabled] and the provider name for every request +// targeting name. +func disabledProviderHandler(name string, logger slog.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + logger.Debug(r.Context(), "refusing request for disabled ai provider", + slog.F("provider", name), + slog.F("path", r.URL.Path), + slog.F("method", r.Method), + ) + http.Error(w, fmt.Sprintf("%s: AI provider %q is disabled", ErrorCodeProviderDisabled, name), http.StatusServiceUnavailable) + } +} + // newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request // using [Provider] p, recording all usage events using [Recorder] rec. // If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple. diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go index f2657ab80f..93beb82de9 100644 --- a/aibridge/bridge_test.go +++ b/aibridge/bridge_test.go @@ -205,3 +205,58 @@ func TestPassthroughRoutesForProviders(t *testing.T) { }) } } + +// TestDisabledProviderHandler asserts that requests to a disabled +// provider return a 503 with an ErrorCodeProviderDisabled body and +// that a sibling enabled provider keeps routing normally. +func TestDisabledProviderHandler(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("upstream-reached")) + })) + t.Cleanup(upstream.Close) + + enabled := aibridge.NewOpenAIProvider(config.OpenAI{Name: "enabled-openai", BaseURL: upstream.URL}) + disabled := aibridge.NewDisabledProviderStub("disabled-openai", "openai") + bridge, err := aibridge.NewRequestBridge( + t.Context(), + []provider.Provider{enabled, disabled}, + nil, nil, logger, nil, bridgeTestTracer, + ) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + }{ + {name: "Bridged", path: "/disabled-openai/v1/chat/completions"}, + {name: "Passthrough", path: "/disabled-openai/v1/models"}, + {name: "Unknown", path: "/disabled-openai/anything/else"}, + } { + t.Run("DisabledProviderReturnsSentinel/"+tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, tc.path, nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusServiceUnavailable, resp.Code) + assert.Contains(t, resp.Body.String(), aibridge.ErrorCodeProviderDisabled) + assert.Contains(t, resp.Body.String(), "disabled-openai") + }) + } + + t.Run("EnabledProviderUnaffected", func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "/enabled-openai/v1/models", nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Equal(t, "upstream-reached", resp.Body.String()) + }) +} diff --git a/aibridge/internal/testutil/mockprovider.go b/aibridge/internal/testutil/mockprovider.go index 0fd85d2863..e5015cd870 100644 --- a/aibridge/internal/testutil/mockprovider.go +++ b/aibridge/internal/testutil/mockprovider.go @@ -15,6 +15,7 @@ import ( type MockProvider struct { NameStr string URL string + Disabled bool Bridged []string Passthrough []string InterceptorFunc func(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error) @@ -22,6 +23,7 @@ type MockProvider struct { func (m *MockProvider) Type() string { return m.NameStr } func (m *MockProvider) Name() string { return m.NameStr } +func (m *MockProvider) Enabled() bool { return !m.Disabled } func (m *MockProvider) BaseURL() string { return m.URL } func (m *MockProvider) RoutePrefix() string { return fmt.Sprintf("/%s", m.NameStr) } func (m *MockProvider) BridgedRoutes() []string { return m.Bridged } diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index eb50a3b296..664183f82f 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -95,6 +95,8 @@ func (p *Anthropic) Name() string { return p.cfg.Name } +func (*Anthropic) Enabled() bool { return true } + func (p *Anthropic) RoutePrefix() string { return fmt.Sprintf("/%s", p.Name()) } diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go index 1186e8b253..fd317aadab 100644 --- a/aibridge/provider/copilot.go +++ b/aibridge/provider/copilot.go @@ -78,6 +78,8 @@ func (p *Copilot) Name() string { return p.cfg.Name } +func (*Copilot) Enabled() bool { return true } + func (p *Copilot) BaseURL() string { return p.cfg.BaseURL } diff --git a/aibridge/provider/disabled.go b/aibridge/provider/disabled.go new file mode 100644 index 0000000000..95384b4952 --- /dev/null +++ b/aibridge/provider/disabled.go @@ -0,0 +1,47 @@ +package provider + +import ( + "fmt" + "net/http" + + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/keypool" +) + +// DisabledStub is a Provider placeholder for a configured-but-disabled +// provider. Only Name and Enabled return meaningful values; all other +// methods return empty/nil so the stub never influences routing. +type DisabledStub struct { + name string + providerType string +} + +// NewDisabledStub returns a Provider stub that reports Enabled() == false. +// The type string is preserved so callers can distinguish provider families. +func NewDisabledStub(name, providerType string) *DisabledStub { + return &DisabledStub{name: name, providerType: providerType} +} + +func (d *DisabledStub) Type() string { return d.providerType } +func (d *DisabledStub) Name() string { return d.name } +func (*DisabledStub) Enabled() bool { return false } +func (*DisabledStub) BaseURL() string { return "" } +func (d *DisabledStub) RoutePrefix() string { + return fmt.Sprintf("/%s", d.name) +} +func (*DisabledStub) BridgedRoutes() []string { return nil } +func (*DisabledStub) PassthroughRoutes() []string { return nil } +func (*DisabledStub) AuthHeader() string { return "" } +func (*DisabledStub) KeyFailoverConfig(_ slog.Logger) keypool.KeyFailoverConfig { + return keypool.KeyFailoverConfig{} +} +func (*DisabledStub) CircuitBreakerConfig() *config.CircuitBreaker { return nil } +func (*DisabledStub) APIDumpDir() string { return "" } +func (*DisabledStub) CreateInterceptor(_ http.ResponseWriter, _ *http.Request, _ trace.Tracer) (intercept.Interceptor, error) { + //nolint:nilnil // disabled providers never reach the interceptor. + return nil, nil +} diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go index 177ae03409..7a78069df6 100644 --- a/aibridge/provider/openai.go +++ b/aibridge/provider/openai.go @@ -84,6 +84,8 @@ func (p *OpenAI) Name() string { return p.cfg.Name } +func (*OpenAI) Enabled() bool { return true } + func (p *OpenAI) RoutePrefix() string { // Route prefix includes version to match default OpenAI base URL. // More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152 diff --git a/aibridge/provider/provider.go b/aibridge/provider/provider.go index 7520333b53..6f21d7290d 100644 --- a/aibridge/provider/provider.go +++ b/aibridge/provider/provider.go @@ -53,6 +53,8 @@ type Provider interface { // Name returns the provider instance name. // Defaults to Type() when not explicitly configured. Name() string + // Enabled reports whether the provider should serve requests. + Enabled() bool // BaseURL defines the base URL endpoint for this provider's API. BaseURL() string diff --git a/cli/aibridged.go b/cli/aibridged.go index caf67082fc..a890488a10 100644 --- a/cli/aibridged.go +++ b/cli/aibridged.go @@ -4,6 +4,7 @@ package cli import ( "context" + "slices" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" @@ -101,10 +102,18 @@ func (r *poolDBReloader) Reload(ctx context.Context) error { return nil } -// BuildProviders loads every ai_providers row (including disabled) -// and returns the active provider list plus per-row outcomes. Per-row -// build errors are logged and excluded from providers but recorded in -// outcomes; only DB query failures propagate. +// BuildProviders loads all ai_providers rows (enabled and disabled), +// attaches keys to enabled rows, and constructs the equivalent +// [aibridge.Provider] instances. The database is the single source of +// truth for runtime provider configuration. +// +// Disabled rows produce a Provider stub with Enabled() == false so the +// bridge can answer requests targeting them with a 503 sentinel. +// +// Per-provider construction errors are logged and the offending row is +// excluded from the returned snapshot; only a failure of the DB query +// itself is propagated. This keeps a single misconfigured row from +// taking the whole daemon down. func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridgeConfig, logger slog.Logger) ([]aibridge.Provider, []aibridged.ProviderOutcome, error) { //nolint:gocritic // AsAIBridged has a minimal permission set for this purpose. authCtx := dbauthz.AsAIBridged(ctx) @@ -160,12 +169,9 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg Name: row.Name, Type: string(row.Type), } - if !row.Enabled { - outcome.Status = aibridged.ProviderStatusDisabled - outcomes = append(outcomes, outcome) - continue + if row.Enabled { + enabledCount++ } - enabledCount++ prov, err := buildAIProviderFromRow(row, keysByProvider[row.ID], cfg) if err != nil { outcome.Status = aibridged.ProviderStatusError @@ -179,13 +185,17 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg ) continue } - outcome.Status = aibridged.ProviderStatusEnabled + if row.Enabled { + outcome.Status = aibridged.ProviderStatusEnabled + } else { + outcome.Status = aibridged.ProviderStatusDisabled + } outcomes = append(outcomes, outcome) providers = append(providers, prov) } - if enabledCount > 0 && len(providers) == 0 { - logger.Warn(ctx, "all enabled ai providers failed to build; daemon will start with zero providers") + if enabledCount > 0 && !slices.ContainsFunc(providers, func(p aibridge.Provider) bool { return p.Enabled() }) { + logger.Warn(ctx, "all enabled ai providers failed to build; only disabled providers remain") } return providers, outcomes, nil @@ -193,11 +203,18 @@ func BuildProviders(ctx context.Context, db database.Store, cfg codersdk.AIBridg // buildAIProviderFromRow decodes the settings blob and constructs the // appropriate [aibridge.Provider] for a single ai_providers row. +// Disabled rows return a Provider stub carrying only Name and +// Disabled: true; settings decode, key loading, and credential checks +// are skipped because the provider will never call upstream. func buildAIProviderFromRow( row database.AIProvider, keys []database.AIProviderKey, cfg codersdk.AIBridgeConfig, ) (aibridge.Provider, error) { + if !row.Enabled { + return disabledProviderFromRow(row) + } + settings, err := db2sdk.AIProviderSettings(row.Settings) if err != nil { return nil, xerrors.Errorf("decode settings: %w", err) @@ -287,6 +304,14 @@ func buildAIProviderFromRow( } } +// disabledProviderFromRow builds a Provider stub for a disabled row. +// Using provider.DisabledStub rather than a concrete provider avoids +// duplicating the row.Type switch and ensures that a new AiProviderType +// value is automatically handled without requiring a matching case here. +func disabledProviderFromRow(row database.AIProvider) (aibridge.Provider, error) { + return aibridge.NewDisabledProviderStub(row.Name, string(row.Type)), nil +} + // buildAIProviderKeyPool builds a [keypool.Pool]. Callers must check // len(keys) > 0 first; keypool.New rejects empty input. func buildAIProviderKeyPool(keys []database.AIProviderKey) (*keypool.Pool, error) { diff --git a/cli/aibridged_internal_test.go b/cli/aibridged_internal_test.go index 0226974520..6b3e1eb7ac 100644 --- a/cli/aibridged_internal_test.go +++ b/cli/aibridged_internal_test.go @@ -393,25 +393,60 @@ func TestBuildProvidersSkipsBadRows(t *testing.T) { t.Run("DisabledRowClassifiedAsDisabled", func(t *testing.T) { t.Parallel() - db, _ := dbtestutil.NewDB(t) - ctx := testutil.Context(t, testutil.WaitShort) - logger := slogtest.Make(t, nil) - dbgen.AIProvider(t, db, database.AIProvider{ - Type: database.AiProviderTypeOpenai, - Name: "openai-off", - BaseUrl: "https://api.openai.com/", - }, func(p *database.InsertAIProviderParams) { - p.Enabled = false - }) + for _, tc := range []struct { + name string + row database.AIProvider + }{ + { + name: "OpenAI", + row: database.AIProvider{ + Type: database.AiProviderTypeOpenai, + Name: "openai-off", + BaseUrl: "https://api.openai.com/", + }, + }, + { + // Anthropic and Bedrock have stricter credential checks + // than the OpenAI family; the disabled short-circuit + // must reach them too. No keys, no bedrock settings. + name: "Anthropic", + row: database.AIProvider{ + Type: database.AiProviderTypeAnthropic, + Name: "anthropic-off", + BaseUrl: "https://api.anthropic.com/", + }, + }, + { + name: "Bedrock", + row: database.AIProvider{ + Type: database.AiProviderTypeBedrock, + Name: "bedrock-off", + BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + logger := slogtest.Make(t, nil) - providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) - require.NoError(t, err) - assert.Empty(t, providers, "disabled providers must not be in the active snapshot") - require.Len(t, outcomes, 1) - assert.Equal(t, "openai-off", outcomes[0].Name) - assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) - assert.NoError(t, outcomes[0].Err) + dbgen.AIProvider(t, db, tc.row, func(p *database.InsertAIProviderParams) { + p.Enabled = false + }) + + providers, outcomes, err := BuildProviders(ctx, db, codersdk.AIBridgeConfig{}, logger) + require.NoError(t, err) + require.Len(t, providers, 1, "disabled providers stay in the snapshot so the bridge can serve a 503 sentinel") + assert.Equal(t, tc.row.Name, providers[0].Name()) + assert.False(t, providers[0].Enabled()) + require.Len(t, outcomes, 1) + assert.Equal(t, tc.row.Name, outcomes[0].Name) + assert.Equal(t, aibridged.ProviderStatusDisabled, outcomes[0].Status) + assert.NoError(t, outcomes[0].Err) + }) + } }) } diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index 6b712a6352..a91e5b51d2 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -588,6 +588,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenAI", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenai, Name: "openai", BaseUrl: "https://api.openai.com/", @@ -597,6 +598,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Anthropic", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeAnthropic, Name: "anthropic", BaseUrl: "https://api.anthropic.com/", @@ -606,6 +608,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Copilot", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeCopilot, Name: "copilot", BaseUrl: "https://api.githubcopilot.com/", @@ -615,6 +618,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Azure", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeAzure, Name: "azure", BaseUrl: "https://example.openai.azure.com/", @@ -624,6 +628,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Google", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeGoogle, Name: "google", BaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/", @@ -633,6 +638,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenAICompat", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenaiCompat, Name: "openai-compat", BaseUrl: "https://compat.example.com/v1/", @@ -642,6 +648,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "OpenRouter", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeOpenrouter, Name: "openrouter", BaseUrl: "https://openrouter.ai/api/v1/", @@ -651,6 +658,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Vercel", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeVercel, Name: "vercel", BaseUrl: "https://api.v0.dev/v1/", @@ -660,6 +668,7 @@ func TestBuildAIProviderFromRowSetsAPIDumpDir(t *testing.T) { { name: "Bedrock", row: database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeBedrock, Name: "bedrock", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", @@ -694,6 +703,7 @@ func TestBuildAIProviderFromRowBedrockWithoutSettings(t *testing.T) { t.Parallel() _, err := buildAIProviderFromRow(database.AIProvider{ + Enabled: true, Type: database.AiProviderTypeBedrock, Name: "bedrock-no-settings", BaseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com/", diff --git a/coderd/aibridged/pool.go b/coderd/aibridged/pool.go index 3b7e60955c..b86cefe00a 100644 --- a/coderd/aibridged/pool.go +++ b/coderd/aibridged/pool.go @@ -30,7 +30,9 @@ const ( type Pooler interface { Acquire(ctx context.Context, req Request, clientFn ClientFunc, mcpBootstrapper MCPProxyBuilder) (http.Handler, error) // ReplaceProviders swaps the providers used to construct future - // RequestBridge instances and clears the cache. + // RequestBridge instances and clears the cache. Disabled providers + // must be included; the bridge serves a 503 sentinel on their + // routes. ReplaceProviders(providers []aibridge.Provider) Shutdown(ctx context.Context) error } @@ -53,7 +55,8 @@ var _ Pooler = &CachedBridgePool{} type CachedBridgePool struct { cache *ristretto.Cache[string, *aibridge.RequestBridge] - // providers is the live provider set used by new RequestBridge instances. + // providers is the live provider set used by new RequestBridge + // instances. Includes disabled providers. providers atomic.Pointer[[]aibridge.Provider] providerVersion atomic.Int64 logger slog.Logger diff --git a/coderd/aibridged/provider.go b/coderd/aibridged/provider.go index 6fb53e1a93..9d2faa030b 100644 --- a/coderd/aibridged/provider.go +++ b/coderd/aibridged/provider.go @@ -17,9 +17,9 @@ const ( ) // ProviderOutcome classifies one ai_providers row, including disabled -// and errored rows the pool excludes. Err is populated only when -// Status == ProviderStatusError; the build error is already logged at -// the call site. +// rows (which the pool keeps as 503 stubs) and errored rows (which the +// pool excludes). Err is populated only when Status == ProviderStatusError; +// the build error is already logged at the call site. type ProviderOutcome struct { Name string Type string diff --git a/enterprise/coderd/aibridge_reload_test.go b/enterprise/coderd/aibridge_reload_test.go index 65f678df6e..e3370c8f7d 100644 --- a/enterprise/coderd/aibridge_reload_test.go +++ b/enterprise/coderd/aibridge_reload_test.go @@ -211,6 +211,18 @@ func TestAIBridgeProviderHotReload(t *testing.T) { "expected provider %q to stop routing", providerName) } + // requireDisabledSentinel polls until the provider name yields a + // 503 with the provider_disabled body, indicating the disabled + // handler is wired up for the row. + requireDisabledSentinel := func(t *testing.T, providerName string) { + t.Helper() + require.Eventuallyf(t, func() bool { + status, _ := sendRequest(providerName) + return status == http.StatusServiceUnavailable + }, testutil.WaitShort, testutil.IntervalFast, + "expected provider %q to serve the disabled sentinel", providerName) + } + // 1. Create: provider points at upstream A. created, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{ Type: codersdk.AIProviderTypeOpenAI, @@ -233,14 +245,14 @@ func TestAIBridgeProviderHotReload(t *testing.T) { requireRoutesTo(t, "primary", upstreamB) requireProviderStatus(t, "primary", "enabled") - // 3. Disable: the provider drops out of the snapshot, requests - // stop reaching any upstream. The metric flips to "disabled". + // 3. Disable: requests stop reaching upstream and the bridge + // answers with the 503 sentinel. The metric flips to "disabled". disabled := false _, err = client.UpdateAIProvider(ctx, "primary", codersdk.UpdateAIProviderRequest{ Enabled: &disabled, }) require.NoError(t, err) - requireRoutingGone(t, "primary") + requireDisabledSentinel(t, "primary") requireProviderStatus(t, "primary", "disabled") // 4. Re-enable: routing comes back at the most recent BaseURL. From 2af037ce02e620b798202e898476b8b318ac6709 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 29 May 2026 11:25:06 +0300 Subject: [PATCH 03/14] fix(cli): use quartz mock clock in PausedDuringWaitForReady test (#25811) PausedDuringWaitForReady used the real clock, so the 5s poll in waitForTaskIdle could race with an in-flight stop build. The SQL view (tasks_with_status) returns "unknown" for stop builds with job_status != "succeeded" because the build_status CASE has no branch for (stop, pending) or (stop, running). On macOS CI, where the provisioner is slower, the poll fires during this transient window and hits the TaskStatusUnknown case instead of TaskStatusPaused, failing with "task entered unknown state" rather than the expected "was paused". Convert to the same quartz mock clock pattern that PR #25648 applied to WaitsForWorkingAppState: inject a mock clock via NewWithClock, trap ticker creation and reset, then advance time deterministically so the poll fires after the stop build completes. Closes CODAGT-482 --- cli/task_send_test.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cli/task_send_test.go b/cli/task_send_test.go index e545da80d1..c90cb335cc 100644 --- a/cli/task_send_test.go +++ b/cli/task_send_test.go @@ -245,8 +245,15 @@ func Test_TaskSend(t *testing.T) { pauseTask(setupCtx, t, setup.userClient, setup.task) resumeTask(setupCtx, t, setup.userClient, setup.task) + // Set up mock clock and traps before starting the command. + // Without a mock clock the poll can race with the stop build + // and see a transient 'unknown' status instead of 'paused'. + mClock := quartz.NewMock(t) + tickTrap := mClock.Trap().NewTicker("task_send", "poll") + resetTrap := mClock.Trap().TickerReset("task_send", "poll") + // When: We attempt to send input to the initializing task. - inv, root := clitest.New(t, "task", "send", setup.task.Name, "some task input") + inv, root := clitest.NewWithClock(t, mClock, "task", "send", setup.task.Name, "some task input") clitest.SetupConfig(t, setup.userClient, root) ctx := testutil.Context(t, testutil.WaitLong) @@ -259,11 +266,29 @@ func Test_TaskSend(t *testing.T) { // of waitForTaskIdle. pty.ExpectMatchContext(ctx, "Waiting for task to become idle") + // Wait for ticker creation and release it. + tickCall := tickTrap.MustWait(ctx) + tickCall.MustRelease(ctx) + tickTrap.Close() + + // Fire the immediate first poll (time.Nanosecond initial interval). + // This poll sees 'initializing' because no agent is connected. + mClock.Advance(time.Nanosecond).MustWait(ctx) + + // Wait for Reset (confirms first poll completed). + resetCall := resetTrap.MustWait(ctx) + resetCall.MustRelease(ctx) + resetTrap.Close() + // Pause the task while waitForTaskIdle is polling. Since // no agent is connected, the task stays initializing until // we pause it, at which point the status becomes paused. pauseTask(ctx, t, setup.userClient, setup.task) + // Fire second poll at the regular 5s interval. The stop + // build has completed, so the poll sees 'paused'. + mClock.Advance(5 * time.Second).MustWait(ctx) + // Then: The command should fail because the task was paused. err := w.Wait() require.Error(t, err) From 78d556fffc85e9b929638496d1d794c92df74bfc Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 11:34:54 +0100 Subject: [PATCH 04/14] feat(site/src/pages/AgentsPage): show error details for generic errors (#25803) Error messages in agent chat now expose the actual error detail instead of hiding it entirely. Also captures API response detail for generic errors that previously dropped it. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 4 +++- .../ChatConversation/ChatStatusCallout.tsx | 16 +++++++++++----- .../ChatConversation/LiveStreamTail.stories.tsx | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 8eca4e5f7d..a6d968d8af 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -1163,9 +1163,11 @@ const AgentChatPage: FC = () => { store.setStreamError(reason); setChatErrorReason(agentId, reason); } else if (isApiError(error)) { + const detail = error.response?.data?.detail?.trim() || undefined; const reason: ChatDetailError = { kind: "generic", - message: error.message || "An unexpected error occurred.", + message: getErrorMessage(error, "An unexpected error occurred."), + ...(detail ? { detail } : {}), }; store.setStreamError(reason); setChatErrorReason(agentId, reason); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx index 9cf3d8bc5b..857d15a5eb 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ChatStatusCallout.tsx @@ -157,11 +157,17 @@ const StatusAlert: FC<{ status: RetryOrFailedStatus }> = ({ status }) => { )} - {status.phase === "failed" && status.detail && ( - - {status.detail} - - )} + {status.phase === "failed" && + status.detail && + (status.kind === "generic" ? ( + + {status.detail} + + ) : ( + + {status.detail} + + ))} ); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index 08fd59d1bb..b58a6b8ebe 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -317,7 +317,7 @@ export const GenericErrorDoesNotShowUsageAction: Story = { }, }; -/** Provider detail renders as a muted secondary line under the main error. */ +/** Provider detail renders in a monospace block for generic errors. */ export const GenericErrorShowsProviderDetail: Story = { args: { ...defaultArgs, From 88060b846ec2267f911c015b8dba94595e3530d1 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 29 May 2026 11:35:05 +0100 Subject: [PATCH 05/14] style(site): use lightbulb icon for thinking (#25844) --- .../AgentsPage/components/ChatElements/tools/ToolIcon.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx index dd8cb1cd04..c2ff74debb 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/tools/ToolIcon.tsx @@ -4,12 +4,12 @@ import { CompassIcon, FilePenLineIcon, FileTextIcon, + LightbulbIcon, LogInIcon, MonitorIcon, PowerIcon, RouteIcon, ServerIcon, - SparklesIcon, TerminalIcon, WrenchIcon, } from "lucide-react"; @@ -118,7 +118,7 @@ export const ToolIcon: React.FC<{ case "chat_summarized": return ; case "thinking": - return ; + return ; case "propose_plan": return ; case "ask_user_question": From a586b7e5e0d93e7674bcb5bdfb5c482eac6feea0 Mon Sep 17 00:00:00 2001 From: Sas Swart Date: Fri, 29 May 2026 12:50:39 +0200 Subject: [PATCH 06/14] feat: add `boundary_log` rbac resource (#24810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC: [Bridge ↔ Boundaries Correlation RFC](https://www.notion.so/coderhq/Gateway-and-Firewall-Correlation-RFC-31ad579be592803aa8b3d48348ccdde9) Register a dedicated `boundary_log` RBAC resource type with `create`, `read`, and `delete` actions, replacing the placeholder `rbac.ResourceAuditLog` and `rbac.ResourceSystem` references previously used in the dbauthz layer. Create is granted at user-level so workspace agents can only write logs owned by their workspace owner, preventing cross-workspace log fabrication. Delete is restricted to `DBPurge` only; no human role (including owner) can delete boundary logs. | Subject | Create (own) | Create (other) | Read (all) | Delete | |---|---|---|---|---| | Workspace agent | yes | no | no | no | | Owner (site admin) | yes (via member) | no | yes | no | | Auditor | no | no | yes | no | | DBPurge | no | no | no | yes | ### Changes - **RBAC policy & resource definition**: add `boundary_log` to `policy.go` and generate `ResourceBoundaryLog` object, scope constants, and codersdk/TypeScript types. - **dbauthz authorization**: replace all `ResourceAuditLog`/`ResourceSystem` placeholders with `ResourceBoundaryLog`. `InsertBoundaryLog` and `InsertBoundarySession` derive the workspace owner from the agent and authorize with `.WithOwner()` for user-scoped create. - **Role assignments:** - **Owner (site):** read only. Excluded from `allPermsExcept` wildcard; create is inherited from member at user-level. - **Member (user-level):** create. User-scoped so agents can only write logs they own. - **Auditor (site):** read. - `boundary_log` is excluded from org-admin, org-member, and org-service-account `allPermsExcept` calls for consistency with `ResourceBoundaryUsage`. - **System subjects:** - **DB Purge** (`SubjectTypeDBPurge`): delete. The only subject that can remove boundary logs. - **Workspace agent scope**: `ResourceBoundaryLog` with wildcard ID in the agent scope allow-list (necessary for creation since no pre-existing ID exists). User-level role scoping prevents deployment-wide access. - **DB migration** (`000510_boundary_log_scopes`): add `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read` enum values to `api_key_scope`. - **Test coverage**: `BoundaryLogCreate` (user-scoped, only matching owner succeeds), `BoundaryLogDelete` (all human roles denied), `BoundaryLogRead` (owner + auditor). dbauthz mock tests set up workspace agent lookups for owner derivation. - **Generated docs**: update OpenAPI specs, API reference docs, and frontend type definitions. --------- Co-authored-by: Muhammad Danish Co-authored-by: Coder Agents --- coderd/apidoc/docs.go | 10 + coderd/apidoc/swagger.json | 10 + coderd/database/dbauthz/dbauthz.go | 39 ++-- coderd/database/dbauthz/dbauthz_test.go | 48 +++-- coderd/database/dbgen/dbgen.go | 59 ++++-- coderd/database/dbmetrics/querymetrics.go | 8 +- coderd/database/dbmock/dbmock.go | 14 +- coderd/database/dump.sql | 14 +- coderd/database/foreign_key_constraint.go | 1 + .../000511_boundary_log_scopes.down.sql | 1 + .../000511_boundary_log_scopes.up.sql | 5 + .../000512_boundary_session_owner.down.sql | 2 + .../000512_boundary_session_owner.up.sql | 28 +++ .../000512_boundary_session_owner.up.sql | 42 ++++ coderd/database/modelmethods.go | 7 + coderd/database/models.go | 16 +- coderd/database/querier.go | 2 +- coderd/database/queries.sql.go | 129 +++++++----- coderd/database/queries/boundarylogs.sql | 27 +-- coderd/rbac/object_gen.go | 10 + coderd/rbac/policy/policy.go | 7 + coderd/rbac/roles.go | 18 +- coderd/rbac/roles_test.go | 187 ++++++++++++++++++ coderd/rbac/scopes.go | 5 + coderd/rbac/scopes_constants_gen.go | 9 + codersdk/apikey_scopes_gen.go | 4 + codersdk/rbacresources_gen.go | 2 + docs/reference/api/members.md | 40 ++-- docs/reference/api/schemas.md | 12 +- docs/reference/api/users.md | 10 +- site/src/api/rbacresourcesGenerated.ts | 5 + site/src/api/typesGenerated.ts | 10 + 32 files changed, 627 insertions(+), 154 deletions(-) create mode 100644 coderd/database/migrations/000511_boundary_log_scopes.down.sql create mode 100644 coderd/database/migrations/000511_boundary_log_scopes.up.sql create mode 100644 coderd/database/migrations/000512_boundary_session_owner.down.sql create mode 100644 coderd/database/migrations/000512_boundary_session_owner.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 22616628d9..835823ecf4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15199,6 +15199,10 @@ const docTemplate = `{ "audit_log:*", "audit_log:create", "audit_log:read", + "boundary_log:*", + "boundary_log:create", + "boundary_log:delete", + "boundary_log:read", "boundary_usage:*", "boundary_usage:delete", "boundary_usage:read", @@ -15425,6 +15429,10 @@ const docTemplate = `{ "APIKeyScopeAuditLogAll", "APIKeyScopeAuditLogCreate", "APIKeyScopeAuditLogRead", + "APIKeyScopeBoundaryLogAll", + "APIKeyScopeBoundaryLogCreate", + "APIKeyScopeBoundaryLogDelete", + "APIKeyScopeBoundaryLogRead", "APIKeyScopeBoundaryUsageAll", "APIKeyScopeBoundaryUsageDelete", "APIKeyScopeBoundaryUsageRead", @@ -22223,6 +22231,7 @@ const docTemplate = `{ "assign_org_role", "assign_role", "audit_log", + "boundary_log", "boundary_usage", "chat", "connection_log", @@ -22273,6 +22282,7 @@ const docTemplate = `{ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceBoundaryLog", "ResourceBoundaryUsage", "ResourceChat", "ResourceConnectionLog", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b32ea11968..db7cbf6809 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13595,6 +13595,10 @@ "audit_log:*", "audit_log:create", "audit_log:read", + "boundary_log:*", + "boundary_log:create", + "boundary_log:delete", + "boundary_log:read", "boundary_usage:*", "boundary_usage:delete", "boundary_usage:read", @@ -13821,6 +13825,10 @@ "APIKeyScopeAuditLogAll", "APIKeyScopeAuditLogCreate", "APIKeyScopeAuditLogRead", + "APIKeyScopeBoundaryLogAll", + "APIKeyScopeBoundaryLogCreate", + "APIKeyScopeBoundaryLogDelete", + "APIKeyScopeBoundaryLogRead", "APIKeyScopeBoundaryUsageAll", "APIKeyScopeBoundaryUsageDelete", "APIKeyScopeBoundaryUsageRead", @@ -20366,6 +20374,7 @@ "assign_org_role", "assign_role", "audit_log", + "boundary_log", "boundary_usage", "chat", "connection_log", @@ -20416,6 +20425,7 @@ "ResourceAssignOrgRole", "ResourceAssignRole", "ResourceAuditLog", + "ResourceBoundaryLog", "ResourceBoundaryUsage", "ResourceChat", "ResourceConnectionLog", diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3e41260d82..5e98dfcf30 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -651,6 +651,8 @@ var ( rbac.ResourceAibridgeInterception.Type: {policy.ActionDelete}, // Chat auto-archive sets archived=true on inactive chats. rbac.ResourceChat.Type: {policy.ActionRead, policy.ActionUpdate}, + // Purge old boundary logs past the retention period. + rbac.ResourceBoundaryLog.Type: {policy.ActionDelete}, }), User: []rbac.Permission{}, ByOrgID: map[string]rbac.OrgPermissions{}, @@ -2191,9 +2193,8 @@ func (q *querier) DeleteOldAuditLogs(ctx context.Context, arg database.DeleteOld return q.db.DeleteOldAuditLogs(ctx, arg) } -// TODO (PR #24810): Replace rbac.ResourceSystem with dedicated boundary_log resource type. func (q *querier) DeleteOldBoundaryLogs(ctx context.Context, arg database.DeleteOldBoundaryLogsParams) (int64, error) { - if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceSystem); err != nil { + if err := q.authorizeContext(ctx, policy.ActionDelete, rbac.ResourceBoundaryLog); err != nil { return 0, err } return q.db.DeleteOldBoundaryLogs(ctx, arg) @@ -2780,17 +2781,15 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI return q.db.GetAuthorizationUserRoles(ctx, userID) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (database.BoundaryLog, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil { return database.BoundaryLog{}, err } return q.db.GetBoundaryLogByID(ctx, id) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (database.BoundarySession, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil { return database.BoundarySession{}, err } return q.db.GetBoundarySessionByID(ctx, id) @@ -5468,14 +5467,29 @@ func (q *querier) InsertAuditLog(ctx context.Context, arg database.InsertAuditLo return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertAuditLog)(ctx, arg) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. -func (q *querier) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { - return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundaryLog)(ctx, arg) +func (q *querier) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { + session, err := q.db.GetBoundarySessionByID(ctx, arg.SessionID) + if err != nil { + return nil, xerrors.Errorf("get boundary session for owner: %w", err) + } + if err := q.authorizeContext(ctx, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(session.OwnerID.UUID.String())); err != nil { + return nil, err + } + return q.db.InsertBoundaryLogs(ctx, arg) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) InsertBoundarySession(ctx context.Context, arg database.InsertBoundarySessionParams) (database.BoundarySession, error) { - return insert(q.log, q.auth, rbac.ResourceAuditLog, q.db.InsertBoundarySession)(ctx, arg) + row, err := q.db.GetWorkspaceAgentAndWorkspaceByID(ctx, arg.WorkspaceAgentID) + if err != nil { + return database.BoundarySession{}, xerrors.Errorf("get workspace for boundary session owner: %w", err) + } + arg.OwnerID = uuid.NullUUID{UUID: row.WorkspaceTable.OwnerID, Valid: true} + if err := q.authorizeContext(ctx, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(arg.OwnerID.UUID.String())); err != nil { + return database.BoundarySession{}, err + } + return q.db.InsertBoundarySession(ctx, arg) } func (q *querier) InsertChat(ctx context.Context, arg database.InsertChatParams) (database.Chat, error) { @@ -6191,9 +6205,8 @@ func (q *querier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Context, return q.db.ListAIBridgeUserPromptsByInterceptionIDs(ctx, interceptionIDs) } -// TODO (PR #24810): Replace rbac.ResourceAuditLog with dedicated boundary_log resource type. func (q *querier) ListBoundaryLogsBySessionID(ctx context.Context, arg database.ListBoundaryLogsBySessionIDParams) ([]database.BoundaryLog, error) { - if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAuditLog); err != nil { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceBoundaryLog); err != nil { return nil, err } return q.db.ListBoundaryLogsBySessionID(ctx, arg) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 3296d5cebe..cfc87d3ed4 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -440,35 +440,55 @@ func (s *MethodTestSuite) TestAuditLogs() { })) } -// TODO (PR #24810): These RBAC assertions use placeholder resource types. -// They will be updated when the dedicated boundary_log resource type is added. func (s *MethodTestSuite) TestBoundaryLogs() { - s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.InsertBoundarySessionParams{} - dbm.EXPECT().InsertBoundarySession(gomock.Any(), arg).Return(database.BoundarySession{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) + s.Run("InsertBoundarySession", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + aww := testutil.Fake(s.T(), faker, database.GetWorkspaceAgentAndWorkspaceByIDRow{}) + arg := database.InsertBoundarySessionParams{ + WorkspaceAgentID: aww.WorkspaceAgent.ID, + } + dbm.EXPECT().GetWorkspaceAgentAndWorkspaceByID(gomock.Any(), aww.WorkspaceAgent.ID).Return(aww, nil).AnyTimes() + expectedArg := database.InsertBoundarySessionParams{ + WorkspaceAgentID: aww.WorkspaceAgent.ID, + OwnerID: uuid.NullUUID{UUID: aww.WorkspaceTable.OwnerID, Valid: true}, + } + dbm.EXPECT().InsertBoundarySession(gomock.Any(), expectedArg).Return(database.BoundarySession{}, nil).AnyTimes() + check.Args(arg).Asserts( + rbac.ResourceBoundaryLog.WithOwner(aww.WorkspaceTable.OwnerID.String()), policy.ActionCreate, + ) })) s.Run("GetBoundarySessionByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), uuid.Nil).Return(database.BoundarySession{}, nil).AnyTimes() - check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) - s.Run("InsertBoundaryLog", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { - arg := database.InsertBoundaryLogParams{} - dbm.EXPECT().InsertBoundaryLog(gomock.Any(), arg).Return(database.BoundaryLog{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionCreate) + s.Run("InsertBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + ownerID := uuid.New() + sessionID := uuid.New() + session := database.BoundarySession{ + ID: sessionID, + OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, + } + arg := database.InsertBoundaryLogsParams{ + SessionID: sessionID, + ID: []uuid.UUID{uuid.New(), uuid.New()}, + } + dbm.EXPECT().GetBoundarySessionByID(gomock.Any(), sessionID).Return(session, nil).AnyTimes() + dbm.EXPECT().InsertBoundaryLogs(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() + check.Args(arg).Asserts( + rbac.ResourceBoundaryLog.WithOwner(ownerID.String()), policy.ActionCreate, + ) })) s.Run("GetBoundaryLogByID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().GetBoundaryLogByID(gomock.Any(), uuid.Nil).Return(database.BoundaryLog{}, nil).AnyTimes() - check.Args(uuid.Nil).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + check.Args(uuid.Nil).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) s.Run("ListBoundaryLogsBySessionID", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { arg := database.ListBoundaryLogsBySessionIDParams{} dbm.EXPECT().ListBoundaryLogsBySessionID(gomock.Any(), arg).Return([]database.BoundaryLog{}, nil).AnyTimes() - check.Args(arg).Asserts(rbac.ResourceAuditLog, policy.ActionRead) + check.Args(arg).Asserts(rbac.ResourceBoundaryLog, policy.ActionRead) })) s.Run("DeleteOldBoundaryLogs", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) { dbm.EXPECT().DeleteOldBoundaryLogs(gomock.Any(), database.DeleteOldBoundaryLogsParams{}).Return(int64(0), nil).AnyTimes() - check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceSystem, policy.ActionDelete) + check.Args(database.DeleteOldBoundaryLogsParams{}).Asserts(rbac.ResourceBoundaryLog, policy.ActionDelete) })) } diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 834bad6274..edc1d710cb 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -458,6 +458,7 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess session, err := db.InsertBoundarySession(genCtx, database.InsertBoundarySessionParams{ ID: takeFirst(seed.ID, uuid.New()), WorkspaceAgentID: takeFirst(seed.WorkspaceAgentID, uuid.New()), + OwnerID: takeFirst(seed.OwnerID, uuid.NullUUID{UUID: uuid.New(), Valid: true}), ConfinedProcessName: takeFirst(seed.ConfinedProcessName, "claude-code"), StartedAt: takeFirst(seed.StartedAt, dbtime.Now()), UpdatedAt: takeFirst(seed.UpdatedAt, dbtime.Now()), @@ -466,20 +467,52 @@ func BoundarySession(t testing.TB, db database.Store, seed database.BoundarySess return session } -func BoundaryLog(t testing.TB, db database.Store, seed database.BoundaryLog) database.BoundaryLog { - log, err := db.InsertBoundaryLog(genCtx, database.InsertBoundaryLogParams{ - ID: takeFirst(seed.ID, uuid.New()), - SessionID: seed.SessionID, - SequenceNumber: takeFirst(seed.SequenceNumber, 0), - CapturedAt: takeFirst(seed.CapturedAt, dbtime.Now()), - CreatedAt: takeFirst(seed.CreatedAt, dbtime.Now()), - Proto: takeFirst(seed.Proto, "http"), - Method: takeFirst(seed.Method, "GET"), - Detail: takeFirst(seed.Detail, "https://example.com"), - MatchedRule: seed.MatchedRule, +func BoundaryLogs(t testing.TB, db database.Store, seed []database.BoundaryLog) []database.BoundaryLog { + ids := make([]uuid.UUID, 0, len(seed)) + sessionID := seed[0].SessionID + sequenceNumbers := make([]int32, 0, len(seed)) + capturedAt := make([]time.Time, 0, len(seed)) + createdAt := make([]time.Time, 0, len(seed)) + protos := make([]string, 0, len(seed)) + method := make([]string, 0, len(seed)) + detail := make([]string, 0, len(seed)) + matchedRule := make([]string, 0, len(seed)) + for _, log := range seed { + log = takeFirstBoundaryLog(log) + ids = append(ids, log.ID) + sequenceNumbers = append(sequenceNumbers, log.SequenceNumber) + capturedAt = append(capturedAt, log.CapturedAt) + createdAt = append(createdAt, log.CreatedAt) + protos = append(protos, log.Proto) + method = append(method, log.Method) + detail = append(detail, log.Detail) + matchedRule = append(matchedRule, log.MatchedRule.String) + } + logs, err := db.InsertBoundaryLogs(genCtx, database.InsertBoundaryLogsParams{ + ID: ids, + SessionID: sessionID, + SequenceNumber: sequenceNumbers, + CapturedAt: capturedAt, + CreatedAt: createdAt, + Proto: protos, + Method: method, + Detail: detail, + MatchedRule: matchedRule, }) - require.NoError(t, err, "insert boundary log") - return log + require.NoError(t, err, "insert boundary logs") + return logs +} + +func takeFirstBoundaryLog(seed database.BoundaryLog) database.BoundaryLog { + seed.ID = takeFirst(seed.ID, uuid.New()) + seed.SessionID = takeFirst(seed.SessionID, uuid.New()) + seed.SequenceNumber = takeFirst(seed.SequenceNumber, 0) + seed.CapturedAt = takeFirst(seed.CapturedAt, dbtime.Now()) + seed.CreatedAt = takeFirst(seed.CreatedAt, dbtime.Now()) + seed.Proto = takeFirst(seed.Proto, "http") + seed.Method = takeFirst(seed.Method, "GET") + seed.Detail = takeFirst(seed.Detail, "https://example.com") + return seed } func Template(t testing.TB, db database.Store, seed database.Template) database.Template { diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index fd4537ccec..c963d41d3f 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -3745,11 +3745,11 @@ func (m queryMetricsStore) InsertAuditLog(ctx context.Context, arg database.Inse return r0, r1 } -func (m queryMetricsStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { +func (m queryMetricsStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { start := time.Now() - r0, r1 := m.s.InsertBoundaryLog(ctx, arg) - m.queryLatencies.WithLabelValues("InsertBoundaryLog").Observe(time.Since(start).Seconds()) - m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLog").Inc() + r0, r1 := m.s.InsertBoundaryLogs(ctx, arg) + m.queryLatencies.WithLabelValues("InsertBoundaryLogs").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "InsertBoundaryLogs").Inc() return r0, r1 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 36f8429e8f..75ac8f44ad 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -7034,19 +7034,19 @@ func (mr *MockStoreMockRecorder) InsertAuditLog(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertAuditLog", reflect.TypeOf((*MockStore)(nil).InsertAuditLog), ctx, arg) } -// InsertBoundaryLog mocks base method. -func (m *MockStore) InsertBoundaryLog(ctx context.Context, arg database.InsertBoundaryLogParams) (database.BoundaryLog, error) { +// InsertBoundaryLogs mocks base method. +func (m *MockStore) InsertBoundaryLogs(ctx context.Context, arg database.InsertBoundaryLogsParams) ([]database.BoundaryLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertBoundaryLog", ctx, arg) - ret0, _ := ret[0].(database.BoundaryLog) + ret := m.ctrl.Call(m, "InsertBoundaryLogs", ctx, arg) + ret0, _ := ret[0].([]database.BoundaryLog) ret1, _ := ret[1].(error) return ret0, ret1 } -// InsertBoundaryLog indicates an expected call of InsertBoundaryLog. -func (mr *MockStoreMockRecorder) InsertBoundaryLog(ctx, arg any) *gomock.Call { +// InsertBoundaryLogs indicates an expected call of InsertBoundaryLogs. +func (mr *MockStoreMockRecorder) InsertBoundaryLogs(ctx, arg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLog", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLog), ctx, arg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertBoundaryLogs", reflect.TypeOf((*MockStore)(nil).InsertBoundaryLogs), ctx, arg) } // InsertBoundarySession mocks base method. diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ff743ba902..fad35755b9 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -249,7 +249,11 @@ CREATE TYPE api_key_scope AS ENUM ( 'user_skill:read', 'user_skill:update', 'user_skill:delete', - 'user_skill:*' + 'user_skill:*', + 'boundary_log:*', + 'boundary_log:create', + 'boundary_log:delete', + 'boundary_log:read' ); CREATE TYPE app_sharing_level AS ENUM ( @@ -1485,7 +1489,8 @@ CREATE TABLE boundary_sessions ( workspace_agent_id uuid NOT NULL, confined_process_name text NOT NULL, started_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL + updated_at timestamp with time zone NOT NULL, + owner_id uuid ); COMMENT ON TABLE boundary_sessions IS 'Boundary session metadata. Each row represents a single invocation of a Boundary process wrapping a confined agent.'; @@ -1500,6 +1505,8 @@ COMMENT ON COLUMN boundary_sessions.started_at IS 'Time when the first log for t COMMENT ON COLUMN boundary_sessions.updated_at IS 'Time when the session was last updated.'; +COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.'; + CREATE TABLE boundary_usage_stats ( replica_id uuid NOT NULL, unique_workspaces_count bigint DEFAULT 0 NOT NULL, @@ -4509,6 +4516,9 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; +ALTER TABLE ONLY boundary_sessions + ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; + ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 624f3229b6..a31346b423 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -13,6 +13,7 @@ const ( ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id); ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyBoundaryLogsSessionID ForeignKeyConstraint = "boundary_logs_session_id_fkey" // ALTER TABLE ONLY boundary_logs ADD CONSTRAINT boundary_logs_session_id_fkey FOREIGN KEY (session_id) REFERENCES boundary_sessions(id) ON DELETE CASCADE; + ForeignKeyBoundarySessionsOwnerID ForeignKeyConstraint = "boundary_sessions_owner_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; ForeignKeyBoundarySessionsWorkspaceAgentID ForeignKeyConstraint = "boundary_sessions_workspace_agent_id_fkey" // ALTER TABLE ONLY boundary_sessions ADD CONSTRAINT boundary_sessions_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id); ForeignKeyChatDebugRunsChatID ForeignKeyConstraint = "chat_debug_runs_chat_id_fkey" // ALTER TABLE ONLY chat_debug_runs ADD CONSTRAINT chat_debug_runs_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; ForeignKeyChatDebugStepsChatID ForeignKeyConstraint = "chat_debug_steps_chat_id_fkey" // ALTER TABLE ONLY chat_debug_steps ADD CONSTRAINT chat_debug_steps_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000511_boundary_log_scopes.down.sql b/coderd/database/migrations/000511_boundary_log_scopes.down.sql new file mode 100644 index 0000000000..5a1baaa20c --- /dev/null +++ b/coderd/database/migrations/000511_boundary_log_scopes.down.sql @@ -0,0 +1 @@ +-- No-op for boundary_log scopes: keep enum values to avoid dependency churn. diff --git a/coderd/database/migrations/000511_boundary_log_scopes.up.sql b/coderd/database/migrations/000511_boundary_log_scopes.up.sql new file mode 100644 index 0000000000..12ec141591 --- /dev/null +++ b/coderd/database/migrations/000511_boundary_log_scopes.up.sql @@ -0,0 +1,5 @@ +-- Add boundary_log scopes for RBAC. +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:*'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:create'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:delete'; +ALTER TYPE api_key_scope ADD VALUE IF NOT EXISTS 'boundary_log:read'; diff --git a/coderd/database/migrations/000512_boundary_session_owner.down.sql b/coderd/database/migrations/000512_boundary_session_owner.down.sql new file mode 100644 index 0000000000..3429fee351 --- /dev/null +++ b/coderd/database/migrations/000512_boundary_session_owner.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE boundary_sessions DROP CONSTRAINT IF EXISTS boundary_sessions_owner_id_fkey; +ALTER TABLE boundary_sessions DROP COLUMN IF EXISTS owner_id; diff --git a/coderd/database/migrations/000512_boundary_session_owner.up.sql b/coderd/database/migrations/000512_boundary_session_owner.up.sql new file mode 100644 index 0000000000..d97140df57 --- /dev/null +++ b/coderd/database/migrations/000512_boundary_session_owner.up.sql @@ -0,0 +1,28 @@ +-- Add owner_id to boundary_sessions to avoid expensive JOINs when +-- deriving the workspace owner for RBAC checks during log insertion. +ALTER TABLE boundary_sessions ADD COLUMN owner_id uuid; + +COMMENT ON COLUMN boundary_sessions.owner_id IS 'The ID of the user who owns the workspace. NULL if the user has been deleted.'; + +-- Backfill owner_id from the workspace agent -> workspace -> owner chain. +-- Soft-deleted agents and workspaces are included so that their audit +-- data is preserved. +UPDATE boundary_sessions bs +SET owner_id = w.owner_id +FROM workspace_agents wa +JOIN workspace_resources wr ON wa.resource_id = wr.id +JOIN provisioner_jobs pj ON wr.job_id = pj.id +JOIN workspace_builds wb ON pj.id = wb.job_id +JOIN workspaces w ON wb.workspace_id = w.id +WHERE wa.id = bs.workspace_agent_id + AND pj.type = 'workspace_build'; + +-- Delete any sessions that could not be backfilled (orphaned data +-- with no resolvable workspace agent or workspace build chain). +DELETE FROM boundary_sessions WHERE owner_id IS NULL; + +-- Add FK constraint. SET NULL preserves audit data when a user is +-- hard-deleted; the session and its logs survive with a NULL owner. +ALTER TABLE boundary_sessions + ADD CONSTRAINT boundary_sessions_owner_id_fkey + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE SET NULL; diff --git a/coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql b/coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql new file mode 100644 index 0000000000..d1942bd5a5 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000512_boundary_session_owner.up.sql @@ -0,0 +1,42 @@ +-- Re-insert boundary session and log fixture data after migration 000511 +-- deletes orphaned rows (the original fixture's workspace_agent links to a +-- template_version_import job, not a workspace_build, so the backfill +-- cannot resolve the owner). + +INSERT INTO boundary_sessions ( + id, + workspace_agent_id, + confined_process_name, + started_at, + updated_at, + owner_id +) VALUES ( + 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', + '45e89705-e09d-4850-bcec-f9a937f5d78d', + 'claude-code', + '2026-04-01 10:00:00+00', + '2026-04-01 10:00:00+00', + '30095c71-380b-457a-8995-97b8ee6e5307' +); + +INSERT INTO boundary_logs ( + id, + session_id, + sequence_number, + captured_at, + created_at, + proto, + method, + detail, + matched_rule +) VALUES ( + 'b2c3d4e5-f6a7-4901-bcde-f12345678901', + 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', + 0, + '2026-04-01 10:00:01+00', + '2026-04-01 10:00:00+00', + 'http', + 'GET', + 'https://api.anthropic.com/v1/messages', + 'domain=api.anthropic.com' +); diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index bab9759762..62eb12a1d2 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -1003,3 +1003,10 @@ type UpsertConnectionLogParams struct { func (r GetLatestWorkspaceBuildWithStatusByWorkspaceIDRow) RBACObject() rbac.Object { return r.WorkspaceTable.RBACObject() } + +func (s BoundarySession) RBACObject() rbac.Object { + if s.OwnerID.Valid { + return rbac.ResourceBoundaryLog.WithOwner(s.OwnerID.UUID.String()) + } + return rbac.ResourceBoundaryLog +} diff --git a/coderd/database/models.go b/coderd/database/models.go index 940904385a..6149e12a10 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -320,6 +320,10 @@ const ( ApiKeyScopeUserSkillUpdate APIKeyScope = "user_skill:update" ApiKeyScopeUserSkillDelete APIKeyScope = "user_skill:delete" ApiKeyScopeUserSkill APIKeyScope = "user_skill:*" + ApiKeyScopeBoundaryLog APIKeyScope = "boundary_log:*" + ApiKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create" + ApiKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete" + ApiKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read" ) func (e *APIKeyScope) Scan(src interface{}) error { @@ -580,7 +584,11 @@ func (e APIKeyScope) Valid() bool { ApiKeyScopeUserSkillRead, ApiKeyScopeUserSkillUpdate, ApiKeyScopeUserSkillDelete, - ApiKeyScopeUserSkill: + ApiKeyScopeUserSkill, + ApiKeyScopeBoundaryLog, + ApiKeyScopeBoundaryLogCreate, + ApiKeyScopeBoundaryLogDelete, + ApiKeyScopeBoundaryLogRead: return true } return false @@ -810,6 +818,10 @@ func AllAPIKeyScopeValues() []APIKeyScope { ApiKeyScopeUserSkillUpdate, ApiKeyScopeUserSkillDelete, ApiKeyScopeUserSkill, + ApiKeyScopeBoundaryLog, + ApiKeyScopeBoundaryLogCreate, + ApiKeyScopeBoundaryLogDelete, + ApiKeyScopeBoundaryLogRead, } } @@ -4543,6 +4555,8 @@ type BoundarySession struct { StartedAt time.Time `db:"started_at" json:"started_at"` // Time when the session was last updated. UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + // The ID of the user who owns the workspace. NULL if the user has been deleted. + OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` } // Per-replica boundary usage statistics for telemetry aggregation. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 6b16e0771a..0b3d565b69 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -920,7 +920,7 @@ type sqlcQuerier interface { // every member of the org. InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) - InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) + InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) // updated_at is the retention clock used by DeleteOldChatDebugRuns. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 1c04d4906b..d4cf7468f4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3627,7 +3627,7 @@ func (q *sqlQuerier) GetBoundaryLogByID(ctx context.Context, id uuid.UUID) (Boun } const getBoundarySessionByID = `-- name: GetBoundarySessionByID :one -SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at FROM boundary_sessions WHERE id = $1 +SELECT id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id FROM boundary_sessions WHERE id = $1 ` func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) (BoundarySession, error) { @@ -3639,11 +3639,12 @@ func (q *sqlQuerier) GetBoundarySessionByID(ctx context.Context, id uuid.UUID) ( &i.ConfinedProcessName, &i.StartedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } -const insertBoundaryLog = `-- name: InsertBoundaryLog :one +const insertBoundaryLogs = `-- name: InsertBoundaryLogs :many INSERT INTO boundary_logs ( id, session_id, @@ -3654,62 +3655,80 @@ INSERT INTO boundary_logs ( method, detail, matched_rule -) VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9 -) RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule +) +SELECT + unnest($1 :: uuid[]), + $2 :: uuid, + unnest($3 :: int[]), + unnest($4 :: timestamptz[]), + unnest($5 :: timestamptz[]), + unnest($6 :: text[]), + unnest($7 :: text[]), + unnest($8 :: text[]), + unnest($9 :: text[]) +RETURNING id, session_id, sequence_number, captured_at, created_at, proto, method, detail, matched_rule ` -type InsertBoundaryLogParams struct { - ID uuid.UUID `db:"id" json:"id"` - SessionID uuid.UUID `db:"session_id" json:"session_id"` - SequenceNumber int32 `db:"sequence_number" json:"sequence_number"` - CapturedAt time.Time `db:"captured_at" json:"captured_at"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - Proto string `db:"proto" json:"proto"` - Method string `db:"method" json:"method"` - Detail string `db:"detail" json:"detail"` - MatchedRule sql.NullString `db:"matched_rule" json:"matched_rule"` +type InsertBoundaryLogsParams struct { + ID []uuid.UUID `db:"id" json:"id"` + SessionID uuid.UUID `db:"session_id" json:"session_id"` + SequenceNumber []int32 `db:"sequence_number" json:"sequence_number"` + CapturedAt []time.Time `db:"captured_at" json:"captured_at"` + CreatedAt []time.Time `db:"created_at" json:"created_at"` + Proto []string `db:"proto" json:"proto"` + Method []string `db:"method" json:"method"` + Detail []string `db:"detail" json:"detail"` + MatchedRule []string `db:"matched_rule" json:"matched_rule"` } -func (q *sqlQuerier) InsertBoundaryLog(ctx context.Context, arg InsertBoundaryLogParams) (BoundaryLog, error) { - row := q.db.QueryRowContext(ctx, insertBoundaryLog, - arg.ID, +func (q *sqlQuerier) InsertBoundaryLogs(ctx context.Context, arg InsertBoundaryLogsParams) ([]BoundaryLog, error) { + rows, err := q.db.QueryContext(ctx, insertBoundaryLogs, + pq.Array(arg.ID), arg.SessionID, - arg.SequenceNumber, - arg.CapturedAt, - arg.CreatedAt, - arg.Proto, - arg.Method, - arg.Detail, - arg.MatchedRule, + pq.Array(arg.SequenceNumber), + pq.Array(arg.CapturedAt), + pq.Array(arg.CreatedAt), + pq.Array(arg.Proto), + pq.Array(arg.Method), + pq.Array(arg.Detail), + pq.Array(arg.MatchedRule), ) - var i BoundaryLog - err := row.Scan( - &i.ID, - &i.SessionID, - &i.SequenceNumber, - &i.CapturedAt, - &i.CreatedAt, - &i.Proto, - &i.Method, - &i.Detail, - &i.MatchedRule, - ) - return i, err + if err != nil { + return nil, err + } + defer rows.Close() + var items []BoundaryLog + for rows.Next() { + var i BoundaryLog + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil } const insertBoundarySession = `-- name: InsertBoundarySession :one INSERT INTO boundary_sessions ( id, workspace_agent_id, + owner_id, confined_process_name, started_at, updated_at @@ -3718,22 +3737,25 @@ INSERT INTO boundary_sessions ( $2, $3, $4, - $5 -) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at + $5, + $6 +) RETURNING id, workspace_agent_id, confined_process_name, started_at, updated_at, owner_id ` type InsertBoundarySessionParams struct { - ID uuid.UUID `db:"id" json:"id"` - WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` - ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"` - StartedAt time.Time `db:"started_at" json:"started_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + WorkspaceAgentID uuid.UUID `db:"workspace_agent_id" json:"workspace_agent_id"` + OwnerID uuid.NullUUID `db:"owner_id" json:"owner_id"` + ConfinedProcessName string `db:"confined_process_name" json:"confined_process_name"` + StartedAt time.Time `db:"started_at" json:"started_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBoundarySessionParams) (BoundarySession, error) { row := q.db.QueryRowContext(ctx, insertBoundarySession, arg.ID, arg.WorkspaceAgentID, + arg.OwnerID, arg.ConfinedProcessName, arg.StartedAt, arg.UpdatedAt, @@ -3745,6 +3767,7 @@ func (q *sqlQuerier) InsertBoundarySession(ctx context.Context, arg InsertBounda &i.ConfinedProcessName, &i.StartedAt, &i.UpdatedAt, + &i.OwnerID, ) return i, err } diff --git a/coderd/database/queries/boundarylogs.sql b/coderd/database/queries/boundarylogs.sql index d8c35fd7eb..3abeb618a5 100644 --- a/coderd/database/queries/boundarylogs.sql +++ b/coderd/database/queries/boundarylogs.sql @@ -2,12 +2,14 @@ INSERT INTO boundary_sessions ( id, workspace_agent_id, + owner_id, confined_process_name, started_at, updated_at ) VALUES ( @id, @workspace_agent_id, + @owner_id, @confined_process_name, @started_at, @updated_at @@ -16,7 +18,7 @@ INSERT INTO boundary_sessions ( -- name: GetBoundarySessionByID :one SELECT * FROM boundary_sessions WHERE id = @id; --- name: InsertBoundaryLog :one +-- name: InsertBoundaryLogs :many INSERT INTO boundary_logs ( id, session_id, @@ -27,17 +29,18 @@ INSERT INTO boundary_logs ( method, detail, matched_rule -) VALUES ( - @id, - @session_id, - @sequence_number, - @captured_at, - @created_at, - @proto, - @method, - @detail, - @matched_rule -) RETURNING *; +) +SELECT + unnest(@id :: uuid[]), + @session_id :: uuid, + unnest(@sequence_number :: int[]), + unnest(@captured_at :: timestamptz[]), + unnest(@created_at :: timestamptz[]), + unnest(@proto :: text[]), + unnest(@method :: text[]), + unnest(@detail :: text[]), + unnest(@matched_rule :: text[]) +RETURNING *; -- name: GetBoundaryLogByID :one SELECT * FROM boundary_logs WHERE id = @id; diff --git a/coderd/rbac/object_gen.go b/coderd/rbac/object_gen.go index 340221f611..824cf92fdd 100644 --- a/coderd/rbac/object_gen.go +++ b/coderd/rbac/object_gen.go @@ -89,6 +89,15 @@ var ( Type: "audit_log", } + // ResourceBoundaryLog + // Valid Actions + // - "ActionCreate" :: create boundary log records + // - "ActionDelete" :: delete boundary logs + // - "ActionRead" :: read boundary logs and session metadata + ResourceBoundaryLog = Object{ + Type: "boundary_log", + } + // ResourceBoundaryUsage // Valid Actions // - "ActionDelete" :: delete boundary usage statistics @@ -478,6 +487,7 @@ func AllResources() []Objecter { ResourceAssignOrgRole, ResourceAssignRole, ResourceAuditLog, + ResourceBoundaryLog, ResourceBoundaryUsage, ResourceChat, ResourceConnectionLog, diff --git a/coderd/rbac/policy/policy.go b/coderd/rbac/policy/policy.go index 7d7a42110d..f2b17927bd 100644 --- a/coderd/rbac/policy/policy.go +++ b/coderd/rbac/policy/policy.go @@ -422,6 +422,13 @@ var RBACPermissions = map[string]PermissionDefinition{ ActionRead: "read AI seat state", }, }, + "boundary_log": { + Actions: map[Action]ActionDefinition{ + ActionCreate: "create boundary log records", + ActionRead: "read boundary logs and session metadata", + ActionDelete: "delete boundary logs", + }, + }, "boundary_usage": { Actions: map[Action]ActionDefinition{ ActionRead: "read boundary usage statistics", diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index cbaf49f9c0..1b19947ea6 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -303,7 +303,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Workspace is specifically handled based on the opts.NoOwnerWorkspaceExec. // Owners can inspect and delete personal skills for operability and // abuse handling, but cannot create or edit user-authored instructions. - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceAiSeat), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUserSecret, ResourceUserSkill, ResourceUsageEvent, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat), // This adds back in the Workspace permissions. Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: ownerWorkspaceActions, @@ -313,6 +313,9 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Explicitly setting PrebuiltWorkspace permissions for clarity. // Note: even without PrebuiltWorkspace permissions, access is still granted via Workspace permissions. ResourcePrebuiltWorkspace.Type: {policy.ActionUpdate, policy.ActionDelete}, + // Owners can read all boundary logs. Delete is reserved for + // DBPurge only. Create is user-scoped (inherited from member). + ResourceBoundaryLog.Type: {policy.ActionRead}, })..., ), User: []Permission{}, @@ -332,7 +335,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { denyPermissions..., ), User: append( - allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat), + allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceWorkspace, ResourceUser, ResourceOrganizationMember, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAibridgeInterception, ResourceChat, ResourceAiSeat), Permissions(map[string][]policy.Action{ // Users cannot do create/update/delete on themselves, but they // can read their own details. @@ -342,6 +345,11 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Members can create and update AI Bridge interceptions but // cannot read them back. ResourceAibridgeInterception.Type: {policy.ActionCreate, policy.ActionUpdate}, + // Workspace agents create boundary logs under their owner's + // identity. Create is user-scoped so agents can only write + // logs owned by their workspace owner. + // Read: owners and auditors. Delete: DBPurge only. + ResourceBoundaryLog.Type: {policy.ActionCreate}, })..., ), ByOrgID: map[string]OrgPermissions{}, @@ -366,6 +374,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) { ResourceDeploymentConfig.Type: {policy.ActionRead}, // Allow auditors to query AI Bridge interceptions. ResourceAibridgeInterception.Type: {policy.ActionRead}, + // Allow auditors to read boundary logs. + ResourceBoundaryLog.Type: {policy.ActionRead}, }), User: []Permission{}, ByOrgID: map[string]OrgPermissions{}, @@ -465,7 +475,7 @@ func ReloadBuiltinRoles(opts *RoleOptions) { // Org admins should not have workspace exec perms. organizationID.String(): { Org: append( - allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceAiSeat), + allPermsExcept(ResourceWorkspace, ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceAssignRole, ResourceUserSecret, ResourceBoundaryUsage, ResourceBoundaryLog, ResourceAiSeat), Permissions(map[string][]policy.Action{ ResourceWorkspace.Type: slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH), ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent, policy.ActionUpdateAgent}, @@ -1052,6 +1062,7 @@ func OrgMemberPermissions(org OrgSettings) OrgRolePermissions { ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember, + ResourceBoundaryLog, ResourceAibridgeInterception, // Chat access requires the agents-access role. ResourceChat, @@ -1137,6 +1148,7 @@ func OrgServiceAccountPermissions(org OrgSettings) OrgRolePermissions { ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember, + ResourceBoundaryLog, ResourceAibridgeInterception, // Chat access requires the agents-access role. ResourceChat, diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 0170d308e0..0ac992fc86 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -1229,6 +1229,75 @@ func TestRolePermissions(t *testing.T) { false: {setOtherOrg, setOrgNotMe, memberMe, agentsAccessUser, templateAdmin, userAdmin}, }, }, + { + // Boundary logs: members can create logs they own (user-scoped). + // memberMe and agentsAccessUser have ID == currentUser, so they + // match the resource owner. Other subjects have different IDs. + Name: "BoundaryLogCreate", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceBoundaryLog.WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {memberMe, agentsAccessUser}, + false: { + owner, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, auditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, + { + // Cross-user isolation: no subject can create boundary logs + // owned by a different user. The resource owner is a random + // UUID that does not match any test subject's ID. + Name: "BoundaryLogCreateOther", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceBoundaryLog.WithOwner(uuid.New().String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, auditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, + { + // Boundary logs: only DBPurge can delete. No human role + // has delete; DBPurge is a system subject outside this matrix. + Name: "BoundaryLogDelete", + Actions: []policy.Action{policy.ActionDelete}, + Resource: rbac.ResourceBoundaryLog, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {}, + false: { + owner, memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, auditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, + { + // Boundary logs: owner and auditor get read. + Name: "BoundaryLogRead", + Actions: []policy.Action{policy.ActionRead}, + Resource: rbac.ResourceBoundaryLog, + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, auditor}, + false: { + memberMe, agentsAccessUser, + orgAdmin, otherOrgAdmin, + orgAuditor, otherOrgAuditor, + templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, + userAdmin, orgUserAdmin, otherOrgUserAdmin, + }, + }, + }, { Name: "ChatUsageCRU", Actions: []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, @@ -1471,3 +1540,121 @@ func TestChangeSet(t *testing.T) { }) } } + +// TestWorkspaceAgentScopeBoundaryLog verifies that a real workspace agent +// scope (not ScopeAll) can create boundary logs for its own owner but +// cannot create them for other users, and cannot read or delete them. +func TestWorkspaceAgentScopeBoundaryLog(t *testing.T) { + t.Parallel() + + auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + + ownerID := uuid.New() + otherOwnerID := uuid.New() + workspaceID := uuid.New() + templateID := uuid.New() + versionID := uuid.New() + + agentScope := rbac.WorkspaceAgentScope(rbac.WorkspaceAgentScopeParams{ + WorkspaceID: workspaceID, + OwnerID: ownerID, + TemplateID: templateID, + VersionID: versionID, + }) + + memberRole, err := rbac.RoleByName(rbac.RoleMember()) + require.NoError(t, err) + + agent := rbac.Subject{ + ID: ownerID.String(), + Roles: rbac.Roles{memberRole}, + Scope: agentScope, + }.WithCachedASTValue() + + // Agent can create boundary logs for its own owner. + err = auth.Authorize(context.Background(), agent, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(ownerID.String())) + require.NoError(t, err, "agent should create boundary logs for own owner") + + // Agent cannot create boundary logs for a different owner. + err = auth.Authorize(context.Background(), agent, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String())) + require.Error(t, err, "agent must not create boundary logs for other owner") + + // Agent cannot read boundary logs (even its own owner's). + err = auth.Authorize(context.Background(), agent, policy.ActionRead, + rbac.ResourceBoundaryLog.WithOwner(ownerID.String())) + require.Error(t, err, "agent must not read boundary logs") + + // Agent cannot delete boundary logs (even its own owner's). + err = auth.Authorize(context.Background(), agent, policy.ActionDelete, + rbac.ResourceBoundaryLog.WithOwner(ownerID.String())) + require.Error(t, err, "agent must not delete boundary logs") + + // When the workspace owner is a site admin, the agent scope + // wildcard for boundary_log combined with the owner role's site-level + // read grant means the agent CAN read all boundary logs. This is an + // accepted consequence of the wildcard scope needed for creation. + ownerRole, err := rbac.RoleByName(rbac.RoleOwner()) + require.NoError(t, err) + + adminAgent := rbac.Subject{ + ID: ownerID.String(), + Roles: rbac.Roles{memberRole, ownerRole}, + Scope: agentScope, + }.WithCachedASTValue() + + // Admin-owned agent CAN read boundary logs due to site-level owner + // role + wildcard scope. + err = auth.Authorize(context.Background(), adminAgent, policy.ActionRead, + rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String())) + require.NoError(t, err, "admin agent inherits site-level read via owner role") + + // Admin-owned agent still cannot create boundary logs for another owner + // because member-level create is user-scoped (subject.id must match owner). + err = auth.Authorize(context.Background(), adminAgent, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(otherOwnerID.String())) + require.Error(t, err, "admin agent must not create boundary logs for other owner") +} + +// TestDBPurgeBoundaryLogDelete verifies that the DBPurge system subject +// can delete boundary logs but cannot create or read them. +func TestDBPurgeBoundaryLogDelete(t *testing.T) { + t.Parallel() + + auth := rbac.NewStrictAuthorizer(prometheus.NewRegistry()) + + // Build the DBPurge subject the same way dbauthz does. + dbPurge := rbac.Subject{ + Type: rbac.SubjectTypeDBPurge, + FriendlyName: "DB Purge", + ID: uuid.Nil.String(), + Roles: rbac.Roles([]rbac.Role{ + { + Identifier: rbac.RoleIdentifier{Name: "dbpurge"}, + DisplayName: "DB Purge Daemon", + Site: rbac.Permissions(map[string][]policy.Action{ + rbac.ResourceBoundaryLog.Type: {policy.ActionDelete}, + }), + User: []rbac.Permission{}, + ByOrgID: map[string]rbac.OrgPermissions{}, + }, + }), + Scope: rbac.ScopeAll, + }.WithCachedASTValue() + + // DBPurge can delete boundary logs. + err := auth.Authorize(context.Background(), dbPurge, policy.ActionDelete, + rbac.ResourceBoundaryLog) + require.NoError(t, err, "DBPurge should delete boundary logs") + + // DBPurge cannot create boundary logs. + err = auth.Authorize(context.Background(), dbPurge, policy.ActionCreate, + rbac.ResourceBoundaryLog.WithOwner(uuid.New().String())) + require.Error(t, err, "DBPurge must not create boundary logs") + + // DBPurge cannot read boundary logs. + err = auth.Authorize(context.Background(), dbPurge, policy.ActionRead, + rbac.ResourceBoundaryLog) + require.Error(t, err, "DBPurge must not read boundary logs") +} diff --git a/coderd/rbac/scopes.go b/coderd/rbac/scopes.go index 17e3990c31..7cbec46d74 100644 --- a/coderd/rbac/scopes.go +++ b/coderd/rbac/scopes.go @@ -65,6 +65,11 @@ func WorkspaceAgentScope(params WorkspaceAgentScopeParams) Scope { {Type: ResourceTemplate.Type, ID: params.TemplateID.String()}, {Type: ResourceTemplate.Type, ID: params.VersionID.String()}, {Type: ResourceUser.Type, ID: params.OwnerID.String()}, + // No pre-existing ID for new records; wildcard is required. + // Owner-scoped create (user-level) limits agents to their own + // logs. Adding site-level actions to the member role would + // bypass this and grant deployment-wide access. + {Type: ResourceBoundaryLog.Type, ID: policy.WildcardSymbol}, }, extraAllowList...), } } diff --git a/coderd/rbac/scopes_constants_gen.go b/coderd/rbac/scopes_constants_gen.go index c12cba430a..b664a4371a 100644 --- a/coderd/rbac/scopes_constants_gen.go +++ b/coderd/rbac/scopes_constants_gen.go @@ -33,6 +33,9 @@ const ( ScopeAssignRoleUnassign ScopeName = "assign_role:unassign" ScopeAuditLogCreate ScopeName = "audit_log:create" ScopeAuditLogRead ScopeName = "audit_log:read" + ScopeBoundaryLogCreate ScopeName = "boundary_log:create" + ScopeBoundaryLogDelete ScopeName = "boundary_log:delete" + ScopeBoundaryLogRead ScopeName = "boundary_log:read" ScopeBoundaryUsageDelete ScopeName = "boundary_usage:delete" ScopeBoundaryUsageRead ScopeName = "boundary_usage:read" ScopeBoundaryUsageUpdate ScopeName = "boundary_usage:update" @@ -210,6 +213,9 @@ func (e ScopeName) Valid() bool { ScopeAssignRoleUnassign, ScopeAuditLogCreate, ScopeAuditLogRead, + ScopeBoundaryLogCreate, + ScopeBoundaryLogDelete, + ScopeBoundaryLogRead, ScopeBoundaryUsageDelete, ScopeBoundaryUsageRead, ScopeBoundaryUsageUpdate, @@ -388,6 +394,9 @@ func AllScopeNameValues() []ScopeName { ScopeAssignRoleUnassign, ScopeAuditLogCreate, ScopeAuditLogRead, + ScopeBoundaryLogCreate, + ScopeBoundaryLogDelete, + ScopeBoundaryLogRead, ScopeBoundaryUsageDelete, ScopeBoundaryUsageRead, ScopeBoundaryUsageUpdate, diff --git a/codersdk/apikey_scopes_gen.go b/codersdk/apikey_scopes_gen.go index 7bad39ccc2..4e4fb8d803 100644 --- a/codersdk/apikey_scopes_gen.go +++ b/codersdk/apikey_scopes_gen.go @@ -40,6 +40,10 @@ const ( APIKeyScopeAuditLogAll APIKeyScope = "audit_log:*" APIKeyScopeAuditLogCreate APIKeyScope = "audit_log:create" APIKeyScopeAuditLogRead APIKeyScope = "audit_log:read" + APIKeyScopeBoundaryLogAll APIKeyScope = "boundary_log:*" + APIKeyScopeBoundaryLogCreate APIKeyScope = "boundary_log:create" + APIKeyScopeBoundaryLogDelete APIKeyScope = "boundary_log:delete" + APIKeyScopeBoundaryLogRead APIKeyScope = "boundary_log:read" APIKeyScopeBoundaryUsageAll APIKeyScope = "boundary_usage:*" APIKeyScopeBoundaryUsageDelete APIKeyScope = "boundary_usage:delete" APIKeyScopeBoundaryUsageRead APIKeyScope = "boundary_usage:read" diff --git a/codersdk/rbacresources_gen.go b/codersdk/rbacresources_gen.go index 11b6488182..75b1e82421 100644 --- a/codersdk/rbacresources_gen.go +++ b/codersdk/rbacresources_gen.go @@ -13,6 +13,7 @@ const ( ResourceAssignOrgRole RBACResource = "assign_org_role" ResourceAssignRole RBACResource = "assign_role" ResourceAuditLog RBACResource = "audit_log" + ResourceBoundaryLog RBACResource = "boundary_log" ResourceBoundaryUsage RBACResource = "boundary_usage" ResourceChat RBACResource = "chat" ResourceConnectionLog RBACResource = "connection_log" @@ -89,6 +90,7 @@ var RBACResourceActions = map[RBACResource][]RBACAction{ ResourceAssignOrgRole: {ActionAssign, ActionCreate, ActionDelete, ActionRead, ActionUnassign, ActionUpdate}, ResourceAssignRole: {ActionAssign, ActionRead, ActionUnassign}, ResourceAuditLog: {ActionCreate, ActionRead}, + ResourceBoundaryLog: {ActionCreate, ActionDelete, ActionRead}, ResourceBoundaryUsage: {ActionDelete, ActionRead, ActionUpdate}, ResourceChat: {ActionCreate, ActionDelete, ActionRead, ActionShare, ActionUpdate}, ResourceConnectionLog: {ActionRead, ActionUpdate}, diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index 1556ced557..fae805d3a7 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -193,10 +193,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -326,10 +326,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -459,10 +459,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -554,10 +554,10 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -960,9 +960,9 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | -| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Property | Value(s) | +|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | `application_connect`, `assign`, `create`, `create_agent`, `delete`, `delete_agent`, `read`, `read_personal`, `share`, `ssh`, `start`, `stop`, `unassign`, `update`, `update_agent`, `update_personal`, `use`, `view_insights` | +| `resource_type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 8ab5372f65..cd5f603559 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1444,9 +1444,9 @@ None #### Enumerated Values -| Value(s) | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | +| Value(s) | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ai_model_price:*`, `ai_model_price:read`, `ai_model_price:update`, `ai_provider:*`, `ai_provider:create`, `ai_provider:delete`, `ai_provider:read`, `ai_provider:update`, `ai_seat:*`, `ai_seat:create`, `ai_seat:read`, `aibridge_interception:*`, `aibridge_interception:create`, `aibridge_interception:read`, `aibridge_interception:update`, `all`, `api_key:*`, `api_key:create`, `api_key:delete`, `api_key:read`, `api_key:update`, `application_connect`, `assign_org_role:*`, `assign_org_role:assign`, `assign_org_role:create`, `assign_org_role:delete`, `assign_org_role:read`, `assign_org_role:unassign`, `assign_org_role:update`, `assign_role:*`, `assign_role:assign`, `assign_role:read`, `assign_role:unassign`, `audit_log:*`, `audit_log:create`, `audit_log:read`, `boundary_log:*`, `boundary_log:create`, `boundary_log:delete`, `boundary_log:read`, `boundary_usage:*`, `boundary_usage:delete`, `boundary_usage:read`, `boundary_usage:update`, `chat:*`, `chat:create`, `chat:delete`, `chat:read`, `chat:share`, `chat:update`, `coder:all`, `coder:apikeys.manage_self`, `coder:application_connect`, `coder:templates.author`, `coder:templates.build`, `coder:workspaces.access`, `coder:workspaces.create`, `coder:workspaces.delete`, `coder:workspaces.operate`, `connection_log:*`, `connection_log:read`, `connection_log:update`, `crypto_key:*`, `crypto_key:create`, `crypto_key:delete`, `crypto_key:read`, `crypto_key:update`, `debug_info:*`, `debug_info:read`, `deployment_config:*`, `deployment_config:read`, `deployment_config:update`, `deployment_stats:*`, `deployment_stats:read`, `file:*`, `file:create`, `file:read`, `group:*`, `group:create`, `group:delete`, `group:read`, `group:update`, `group_member:*`, `group_member:read`, `idpsync_settings:*`, `idpsync_settings:read`, `idpsync_settings:update`, `inbox_notification:*`, `inbox_notification:create`, `inbox_notification:read`, `inbox_notification:update`, `license:*`, `license:create`, `license:delete`, `license:read`, `notification_message:*`, `notification_message:create`, `notification_message:delete`, `notification_message:read`, `notification_message:update`, `notification_preference:*`, `notification_preference:read`, `notification_preference:update`, `notification_template:*`, `notification_template:read`, `notification_template:update`, `oauth2_app:*`, `oauth2_app:create`, `oauth2_app:delete`, `oauth2_app:read`, `oauth2_app:update`, `oauth2_app_code_token:*`, `oauth2_app_code_token:create`, `oauth2_app_code_token:delete`, `oauth2_app_code_token:read`, `oauth2_app_secret:*`, `oauth2_app_secret:create`, `oauth2_app_secret:delete`, `oauth2_app_secret:read`, `oauth2_app_secret:update`, `organization:*`, `organization:create`, `organization:delete`, `organization:read`, `organization:update`, `organization_member:*`, `organization_member:create`, `organization_member:delete`, `organization_member:read`, `organization_member:update`, `prebuilt_workspace:*`, `prebuilt_workspace:delete`, `prebuilt_workspace:update`, `provisioner_daemon:*`, `provisioner_daemon:create`, `provisioner_daemon:delete`, `provisioner_daemon:read`, `provisioner_daemon:update`, `provisioner_jobs:*`, `provisioner_jobs:create`, `provisioner_jobs:read`, `provisioner_jobs:update`, `replicas:*`, `replicas:read`, `system:*`, `system:create`, `system:delete`, `system:read`, `system:update`, `tailnet_coordinator:*`, `tailnet_coordinator:create`, `tailnet_coordinator:delete`, `tailnet_coordinator:read`, `tailnet_coordinator:update`, `task:*`, `task:create`, `task:delete`, `task:read`, `task:update`, `template:*`, `template:create`, `template:delete`, `template:read`, `template:update`, `template:use`, `template:view_insights`, `usage_event:*`, `usage_event:create`, `usage_event:read`, `usage_event:update`, `user:*`, `user:create`, `user:delete`, `user:read`, `user:read_personal`, `user:update`, `user:update_personal`, `user_secret:*`, `user_secret:create`, `user_secret:delete`, `user_secret:read`, `user_secret:update`, `user_skill:*`, `user_skill:create`, `user_skill:delete`, `user_skill:read`, `user_skill:update`, `webpush_subscription:*`, `webpush_subscription:create`, `webpush_subscription:delete`, `webpush_subscription:read`, `workspace:*`, `workspace:application_connect`, `workspace:create`, `workspace:create_agent`, `workspace:delete`, `workspace:delete_agent`, `workspace:read`, `workspace:share`, `workspace:ssh`, `workspace:start`, `workspace:stop`, `workspace:update`, `workspace:update_agent`, `workspace_agent_devcontainers:*`, `workspace_agent_devcontainers:create`, `workspace_agent_resource_monitor:*`, `workspace_agent_resource_monitor:create`, `workspace_agent_resource_monitor:read`, `workspace_agent_resource_monitor:update`, `workspace_dormant:*`, `workspace_dormant:application_connect`, `workspace_dormant:create`, `workspace_dormant:create_agent`, `workspace_dormant:delete`, `workspace_dormant:delete_agent`, `workspace_dormant:read`, `workspace_dormant:share`, `workspace_dormant:ssh`, `workspace_dormant:start`, `workspace_dormant:stop`, `workspace_dormant:update`, `workspace_dormant:update_agent`, `workspace_proxy:*`, `workspace_proxy:create`, `workspace_proxy:delete`, `workspace_proxy:read`, `workspace_proxy:update` | ## codersdk.AddLicenseRequest @@ -10818,9 +10818,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| #### Enumerated Values -| Value(s) | -|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| Value(s) | +|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | ## codersdk.RateLimitConfig diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md index 376a415031..0bedde7b0c 100644 --- a/docs/reference/api/users.md +++ b/docs/reference/api/users.md @@ -865,11 +865,11 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | -| `login_type` | `github`, `oidc`, `password`, `token` | -| `scope` | `all`, `application_connect` | +| Property | Value(s) | +|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `type` | `*`, `ai_model_price`, `ai_provider`, `ai_seat`, `aibridge_interception`, `api_key`, `assign_org_role`, `assign_role`, `audit_log`, `boundary_log`, `boundary_usage`, `chat`, `connection_log`, `crypto_key`, `debug_info`, `deployment_config`, `deployment_stats`, `file`, `group`, `group_member`, `idpsync_settings`, `inbox_notification`, `license`, `notification_message`, `notification_preference`, `notification_template`, `oauth2_app`, `oauth2_app_code_token`, `oauth2_app_secret`, `organization`, `organization_member`, `prebuilt_workspace`, `provisioner_daemon`, `provisioner_jobs`, `replicas`, `system`, `tailnet_coordinator`, `task`, `template`, `usage_event`, `user`, `user_secret`, `user_skill`, `webpush_subscription`, `workspace`, `workspace_agent_devcontainers`, `workspace_agent_resource_monitor`, `workspace_dormant`, `workspace_proxy` | +| `login_type` | `github`, `oidc`, `password`, `token` | +| `scope` | `all`, `application_connect` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/rbacresourcesGenerated.ts b/site/src/api/rbacresourcesGenerated.ts index 23bd95350c..2ac260c98a 100644 --- a/site/src/api/rbacresourcesGenerated.ts +++ b/site/src/api/rbacresourcesGenerated.ts @@ -50,6 +50,11 @@ export const RBACResourceActions: Partial< create: "create new audit log entries", read: "read audit logs", }, + boundary_log: { + create: "create boundary log records", + delete: "delete boundary logs", + read: "read boundary logs and session metadata", + }, boundary_usage: { delete: "delete boundary usage statistics", read: "read boundary usage statistics", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 11d33b28a8..74b6041f3b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -554,6 +554,10 @@ export type APIKeyScope = | "audit_log:*" | "audit_log:create" | "audit_log:read" + | "boundary_log:*" + | "boundary_log:create" + | "boundary_log:delete" + | "boundary_log:read" | "boundary_usage:*" | "boundary_usage:delete" | "boundary_usage:read" @@ -780,6 +784,10 @@ export const APIKeyScopes: APIKeyScope[] = [ "audit_log:*", "audit_log:create", "audit_log:read", + "boundary_log:*", + "boundary_log:create", + "boundary_log:delete", + "boundary_log:read", "boundary_usage:*", "boundary_usage:delete", "boundary_usage:read", @@ -6870,6 +6878,7 @@ export type RBACResource = | "assign_org_role" | "assign_role" | "audit_log" + | "boundary_log" | "boundary_usage" | "chat" | "connection_log" @@ -6920,6 +6929,7 @@ export const RBACResources: RBACResource[] = [ "assign_org_role", "assign_role", "audit_log", + "boundary_log", "boundary_usage", "chat", "connection_log", From 7b903cad739f434cb240752972b4b5b2a4b1a9aa Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 29 May 2026 12:01:37 +0100 Subject: [PATCH 07/14] fix: track credential hint across key failover attempts in aibridge (#25735) ## Problem Centralized requests recorded *the first available key from the pool at `CreateInterceptor` time* as `credential_hint`, so the interception could be persisted in the database with a hint that didn't match the key that actually served the request. The fix consists in storing, at end-of-interception, the hint of the key that succeeded, or the last attempted key if all keys are unavailable. ## Changes - Add `Key.Hint()` and update `credential_hint` on every failover attempt so it reflects the actually-used key. - Stop pre-populating `credential_hint` at `CreateInterceptor`. Centralized starts empty and is updated by the key failover loop. - Persist the final hint via `RecordInterceptionEnded`; SQL updates `credential_hint` only when `credential_kind = 'centralized'` so BYOK keeps its start-time value. - Log the actually-used hint on interception end/failure; start log uses a `` placeholder for centralized. > [!NOTE] > Initially generated by Claude Opus 4.7, modified and reviewed by @ssncferreira --- aibridge/bridge.go | 34 +- .../intercept/chatcompletions/blocking.go | 7 +- .../chatcompletions/blocking_internal_test.go | 83 ++-- .../intercept/chatcompletions/streaming.go | 5 + .../streaming_internal_test.go | 83 ++-- aibridge/intercept/messages/blocking.go | 7 +- .../messages/blocking_internal_test.go | 84 ++-- aibridge/intercept/messages/streaming.go | 5 + .../messages/streaming_internal_test.go | 84 ++-- aibridge/intercept/responses/blocking.go | 7 +- .../responses/blocking_internal_test.go | 81 +-- aibridge/intercept/responses/streaming.go | 5 + .../responses/streaming_internal_test.go | 81 +-- aibridge/keypool/keymark.go | 5 +- aibridge/keypool/keypool.go | 7 + aibridge/provider/anthropic.go | 11 +- aibridge/provider/anthropic_internal_test.go | 4 +- aibridge/provider/openai.go | 10 +- aibridge/provider/openai_internal_test.go | 8 +- aibridge/recorder/types.go | 11 +- coderd/aibridged/proto/aibridged.pb.go | 467 +++++++++--------- coderd/aibridged/proto/aibridged.proto | 1 + coderd/aibridged/translator.go | 5 +- coderd/aibridgedserver/aibridgedserver.go | 5 +- .../aibridgedserver/aibridgedserver_test.go | 23 +- coderd/database/dbgen/dbgen.go | 5 +- coderd/database/querier_test.go | 62 ++- coderd/database/queries.sql.go | 18 +- coderd/database/queries/aibridge.sql | 9 +- 29 files changed, 736 insertions(+), 481 deletions(-) diff --git a/aibridge/bridge.go b/aibridge/bridge.go index 79715af880..daf103fb10 100644 --- a/aibridge/bridge.go +++ b/aibridge/bridge.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog/v3" "github.com/coder/coder/v2/aibridge/circuitbreaker" aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/aibridge/metrics" "github.com/coder/coder/v2/aibridge/provider" @@ -275,11 +276,18 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC slog.F("user_agent", r.UserAgent()), slog.F("streaming", interceptor.Streaming()), slog.F("credential_kind", string(cred.Kind)), - slog.F("credential_hint", cred.Hint), - slog.F("credential_length", cred.Length), ) - log.Debug(ctx, "interception started") + // Log BYOK credentials. Centralized credentials are set by + // the key failover loop. + credLogFields := []slog.Field{} + if cred.Kind == intercept.CredentialKindBYOK { + credLogFields = append(credLogFields, + slog.F("credential_hint", cred.Hint), + slog.F("credential_length", cred.Length), + ) + } + log.Debug(ctx, "interception started", credLogFields...) if m != nil { m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1) defer func() { @@ -288,22 +296,30 @@ func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderC } // Process request with circuit breaker protection if configured - if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { + execErr := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { return interceptor.ProcessRequest(rw, r) - }); err != nil { + }) + // For centralized, the hint now reflects the last attempted + // key from the failover loop. + credHint := interceptor.Credential().Hint + credLen := interceptor.Credential().Length + if execErr != nil { if m != nil { m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1) } - span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err)) - log.Warn(ctx, "interception failed", slog.Error(err)) + span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", execErr)) + log.Warn(ctx, "interception failed", slog.Error(execErr), slog.F("credential_hint", credHint), slog.F("credential_length", credLen)) } else { if m != nil { m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1) } - log.Debug(ctx, "interception ended") + log.Debug(ctx, "interception ended", slog.F("credential_hint", credHint), slog.F("credential_length", credLen)) } - _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()}) + _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ + ID: interceptor.ID().String(), + CredentialHint: credHint, + }) // Ensure all recording have completed before completing request. asyncRecorder.Wait() diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go index 95d065ce5b..fa1511f660 100644 --- a/aibridge/intercept/chatcompletions/blocking.go +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -291,15 +291,16 @@ func (i *BlockingInterception) newChatCompletionWithKey(ctx context.Context, svc // 401/403. Errors that aren't key-specific don't trigger // failover and are returned to the caller. func (i *BlockingInterception) newChatCompletionWithKeyFailover(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (*openai.ChatCompletion, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) requestOpts := append([]option.RequestOption{}, opts...) requestOpts = append(requestOpts, diff --git a/aibridge/intercept/chatcompletions/blocking_internal_test.go b/aibridge/intercept/chatcompletions/blocking_internal_test.go index 3b3a917a54..2b9afaadea 100644 --- a/aibridge/intercept/chatcompletions/blocking_internal_test.go +++ b/aibridge/intercept/chatcompletions/blocking_internal_test.go @@ -72,31 +72,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -104,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -120,15 +125,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -136,25 +142,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -168,15 +175,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -184,14 +192,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -199,6 +208,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -219,9 +229,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -252,6 +263,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg := config.OpenAI{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -259,6 +271,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } interceptor := NewBlockingInterceptor( @@ -269,7 +282,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -288,6 +301,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -309,6 +323,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { expectedSeenKeys []string expectedStatusCode int expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -319,12 +336,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -342,12 +360,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -369,12 +388,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -409,7 +429,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -459,6 +479,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/chatcompletions/streaming.go b/aibridge/intercept/chatcompletions/streaming.go index 581ab49d03..e20a2a801d 100644 --- a/aibridge/intercept/chatcompletions/streaming.go +++ b/aibridge/intercept/chatcompletions/streaming.go @@ -164,6 +164,11 @@ func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Re break } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + opts = append(opts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/chatcompletions/streaming_internal_test.go b/aibridge/intercept/chatcompletions/streaming_internal_test.go index 82c58f9bc1..9561c0948a 100644 --- a/aibridge/intercept/chatcompletions/streaming_internal_test.go +++ b/aibridge/intercept/chatcompletions/streaming_internal_test.go @@ -144,36 +144,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -185,16 +189,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -206,15 +211,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -226,6 +232,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -233,19 +240,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -259,15 +266,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -275,14 +283,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -290,6 +299,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -310,9 +320,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -342,6 +353,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg := config.OpenAI{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -349,6 +361,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } interceptor := NewStreamingInterceptor( @@ -359,7 +372,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -378,6 +391,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -435,6 +449,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -445,13 +462,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -469,13 +487,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -497,7 +516,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectErrorAsSSEEvent: true, expectedErr: true, @@ -505,6 +524,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -538,7 +558,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -596,6 +616,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/messages/blocking.go b/aibridge/intercept/messages/blocking.go index e91f80feb9..bf74885b2b 100644 --- a/aibridge/intercept/messages/blocking.go +++ b/aibridge/intercept/messages/blocking.go @@ -367,15 +367,16 @@ func (i *BlockingInterception) newMessageWithKey(ctx context.Context, svc anthro // Errors that aren't key-specific don't trigger failover and // are returned to the caller. func (i *BlockingInterception) newMessageWithKeyFailover(ctx context.Context, svc anthropic.MessageService) (*anthropic.Message, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) msg, err := i.newMessageWithKey(ctx, svc, option.WithAPIKey(key.Value()), diff --git a/aibridge/intercept/messages/blocking_internal_test.go b/aibridge/intercept/messages/blocking_internal_test.go index 857d425fe3..9b3f0d447b 100644 --- a/aibridge/intercept/messages/blocking_internal_test.go +++ b/aibridge/intercept/messages/blocking_internal_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -54,31 +55,35 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -86,15 +91,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -102,15 +108,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -118,25 +125,26 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -150,15 +158,16 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -166,14 +175,15 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -181,6 +191,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -201,9 +212,10 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -234,6 +246,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg := config.Anthropic{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -241,6 +254,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -255,7 +269,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { http.Header{}, "X-Api-Key", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -271,6 +285,7 @@ func TestBlockingInterception_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -296,6 +311,9 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { expectedStatusCode int expectedRetryAfter string expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -306,12 +324,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -329,12 +348,13 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -356,13 +376,14 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedRetryAfter: "3", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -397,7 +418,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.Anthropic{ @@ -447,6 +468,7 @@ func TestBlockingInterception_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/intercept/messages/streaming.go b/aibridge/intercept/messages/streaming.go index 475f32c99c..47c49528a9 100644 --- a/aibridge/intercept/messages/streaming.go +++ b/aibridge/intercept/messages/streaming.go @@ -195,6 +195,11 @@ newStream: break } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + streamOpts = append(streamOpts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/messages/streaming_internal_test.go b/aibridge/intercept/messages/streaming_internal_test.go index 97f48d4cc3..5fc7da00df 100644 --- a/aibridge/intercept/messages/streaming_internal_test.go +++ b/aibridge/intercept/messages/streaming_internal_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/aibridge/internal/testutil" "github.com/coder/coder/v2/aibridge/keypool" "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -60,36 +61,40 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -101,16 +106,17 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -122,15 +128,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -142,6 +149,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -149,19 +157,19 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -175,15 +183,16 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -191,14 +200,15 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -206,6 +216,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -226,9 +237,10 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, - expectedRetryAfter: "5", + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedRetryAfter: "5", + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -258,6 +270,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg := config.Anthropic{BaseURL: upstream.URL + "/"} var pool *keypool.Pool + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") if len(tc.keys) > 0 { var err error pool, err = keypool.New(tc.keys, quartz.NewMock(t)) @@ -265,6 +278,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -279,7 +293,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { http.Header{}, "X-Api-Key", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -301,6 +315,7 @@ func TestStreamingInterception_KeyFailover(t *testing.T) { if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } @@ -387,6 +402,9 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -397,13 +415,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -421,13 +440,14 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectErrorAsSSEEvent: false, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -453,7 +473,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectErrorAsSSEEvent: true, expectedErr: true, @@ -461,6 +481,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -494,7 +515,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.Anthropic{ @@ -553,6 +574,7 @@ func TestStreamingInterception_AgenticLoopFailover(t *testing.T) { defer seenKeysMu.Unlock() assert.Equal(t, tc.expectedSeenKeys, seenKeys, "seen keys") assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") }) } } diff --git a/aibridge/intercept/responses/blocking.go b/aibridge/intercept/responses/blocking.go index 9726b6f750..892dc1e71d 100644 --- a/aibridge/intercept/responses/blocking.go +++ b/aibridge/intercept/responses/blocking.go @@ -171,15 +171,16 @@ func (i *BlockingResponsesInterceptor) newResponseWithKey(ctx context.Context, s // Errors that aren't key-specific don't trigger failover and // are returned to the caller. func (i *BlockingResponsesInterceptor) newResponseWithKeyFailover(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (*responses.Response, error) { - // TODO(ssncferreira): update the interception's credential - // hint with the actually-used key (the successful key on - // success, the last tried key on failure) in the upstack PR. walker := i.cfg.KeyPool.Walker() for { key, keyPoolErr := walker.Next() if keyPoolErr != nil { return nil, keyPoolErr } + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) requestOpts := append([]option.RequestOption{}, opts...) requestOpts = append(requestOpts, diff --git a/aibridge/intercept/responses/blocking_internal_test.go b/aibridge/intercept/responses/blocking_internal_test.go index 678c2ce0f3..94acf0deef 100644 --- a/aibridge/intercept/responses/blocking_internal_test.go +++ b/aibridge/intercept/responses/blocking_internal_test.go @@ -58,31 +58,35 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning 200. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusOK, body: successBody}, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429, key-1 returns 200. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -90,15 +94,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -106,15 +111,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403, key-1 returns 200. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": {statusCode: http.StatusOK, body: successBody}, + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusOK, body: successBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusOK, @@ -122,25 +128,26 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 with cooldowns 5s, 3s, 10s. // Then: 3 requests, 429 response with smallest Retry-After, // all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -154,15 +161,16 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -170,14 +178,15 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -185,6 +194,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -204,8 +214,9 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -235,6 +246,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { t.Cleanup(upstream.Close) cfg := config.OpenAI{BaseURL: upstream.URL + "/"} + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") var pool *keypool.Pool if len(tc.keys) > 0 { var err error @@ -243,6 +255,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(requestBody)) @@ -256,7 +269,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("blocking_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -272,6 +285,7 @@ func TestBlockingResponsesInterceptor_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -296,6 +310,9 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { expectedSeenKeys []string expectedStatusCode int expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -306,12 +323,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -329,12 +347,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, body: textCompleteBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusOK, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -356,12 +375,13 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedStatusCode: http.StatusTooManyRequests, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -396,7 +416,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -444,6 +464,7 @@ func TestBlockingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/intercept/responses/streaming.go b/aibridge/intercept/responses/streaming.go index 2140c5e6c8..3b38b7a7e6 100644 --- a/aibridge/intercept/responses/streaming.go +++ b/aibridge/intercept/responses/streaming.go @@ -144,6 +144,11 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r return xerrors.Errorf("key pool exhausted: %w", keyPoolErr) } currentKey = key + // Record the key in use so the hint reflects the last attempted key. + i.credential = intercept.NewCredentialInfo(intercept.CredentialKindCentralized, key.Value()) + i.logger.Debug(ctx, "using centralized api key", + slog.F("credential_hint", i.Credential().Hint), slog.F("credential_length", i.Credential().Length)) + opts = append(opts, option.WithAPIKey(key.Value()), // Disable SDK retries because the failover diff --git a/aibridge/intercept/responses/streaming_internal_test.go b/aibridge/intercept/responses/streaming_internal_test.go index 3226147cbd..4f20d76c17 100644 --- a/aibridge/intercept/responses/streaming_internal_test.go +++ b/aibridge/intercept/responses/streaming_internal_test.go @@ -51,36 +51,40 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { expectedRetryAfter string // Expected key states after the request, by index in keys. expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: last + // attempted key for centralized, user key from initial request for BYOK. + expectedCredentialHint string }{ { // Given: 1 valid key returning a successful stream. // Then: 1 request, 200 response, key remains valid. name: "single_valid_key", - keys: []string{"k0"}, + keys: []string{"k0-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusOK, - expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedRequestCount: 1, + expectedStatusCode: http.StatusOK, + expectedKeyStates: []keypool.KeyState{keypool.KeyStateValid}, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 returns 429 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 temporary, key-1 valid. name: "failover_after_429", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -92,16 +96,17 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 401 pre-stream, key-1 // streams successfully. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_401", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -113,15 +118,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 403 pre-stream, key-1 streams. // Then: 2 requests, 200 response, key-0 permanent, key-1 valid. name: "failover_after_403", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusForbidden, body: authErrorBody}, - "k1": { + "k0-long-key": {statusCode: http.StatusForbidden, body: authErrorBody}, + "k1-long-key": { statusCode: http.StatusOK, headers: map[string]string{"Content-Type": "text/event-stream"}, body: streamingSuccessBody, @@ -133,6 +139,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 3 keys; all return 429 pre-stream with @@ -140,19 +147,19 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { // Then: 3 requests, 429 response with smallest // Retry-After, all keys temporary. name: "all_keys_rate_limited", - keys: []string{"k0", "k1", "k2"}, + keys: []string{"k0-long-key", "k1-long-key", "k2-long-key"}, responses: map[string]upstreamResponse{ - "k0": { + "k0-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "5"}, body: rateLimitBody, }, - "k1": { + "k1-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "3"}, body: rateLimitBody, }, - "k2": { + "k2-long-key": { statusCode: http.StatusTooManyRequests, headers: map[string]string{"Retry-After": "10"}, body: rateLimitBody, @@ -166,15 +173,16 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k2-long-key"), }, { // Given: 2 keys; both return 401 pre-stream. // Then: 2 requests, 502 api_error response, both keys permanent. name: "all_keys_unauthorized", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusUnauthorized, body: authErrorBody}, - "k1": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k0-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, + "k1-long-key": {statusCode: http.StatusUnauthorized, body: authErrorBody}, }, expectedRequestCount: 2, expectedStatusCode: http.StatusBadGateway, @@ -182,14 +190,15 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStatePermanent, keypool.KeyStatePermanent, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 returns 500 pre-stream. // Then: 1 request, 500 response, both keys remain valid. name: "server_error_no_failover", - keys: []string{"k0", "k1"}, + keys: []string{"k0-long-key", "k1-long-key"}, responses: map[string]upstreamResponse{ - "k0": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, + "k0-long-key": {statusCode: http.StatusInternalServerError, body: serverErrorBody}, }, expectedRequestCount: 1, expectedStatusCode: http.StatusInternalServerError, @@ -197,6 +206,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: BYOK with a single key returning 429. @@ -216,8 +226,9 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { body: rateLimitBody, }, }, - expectedRequestCount: 1, - expectedStatusCode: http.StatusTooManyRequests, + expectedRequestCount: 1, + expectedStatusCode: http.StatusTooManyRequests, + expectedCredentialHint: utils.MaskSecret("user-byok"), }, } @@ -246,6 +257,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { t.Cleanup(upstream.Close) cfg := config.OpenAI{BaseURL: upstream.URL + "/"} + credInfo := intercept.NewCredentialInfo(intercept.CredentialKindCentralized, "") var pool *keypool.Pool if len(tc.keys) > 0 { var err error @@ -254,6 +266,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { cfg.KeyPool = pool } else if tc.byokKey != "" { cfg.Key = tc.byokKey + credInfo = intercept.NewCredentialInfo(intercept.CredentialKindBYOK, tc.byokKey) } payload, err := NewRequestPayload([]byte(streamingRequestBody)) @@ -267,7 +280,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { http.Header{}, "Authorization", otel.Tracer("streaming_test"), - intercept.NewCredentialInfo(intercept.CredentialKindCentralized, ""), + credInfo, ) interceptor.Setup(slog.Make(), &testutil.MockRecorder{}, nil) @@ -283,6 +296,7 @@ func TestStreamingResponsesInterceptor_KeyFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") assert.Equal(t, tc.expectedStatusCode, w.Code, "response status code") assert.Equal(t, tc.expectedRetryAfter, w.Header().Get("Retry-After"), "Retry-After header") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") if pool != nil { assert.Equal(t, tc.expectedKeyStates, pool.PoolState(), "key states") } @@ -339,6 +353,9 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { // error (e.g. all keys exhausted). expectedErr bool expectedKeyStates []keypool.KeyState + // Expected credential hint after ProcessRequest: hint of the + // last attempted key across all agentic-loop iterations. + expectedCredentialHint string }{ { // Given: 2 keys; both upstream calls succeed on key-0. @@ -349,12 +366,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 2, - expectedSeenKeys: []string{"k0", "k0"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key"}, expectedBodyContains: "done", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateValid, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k0-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then 429s @@ -372,12 +390,13 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { {statusCode: http.StatusOK, headers: sseHeaders, body: textStreamBody}, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "done", expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateValid, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, { // Given: 2 keys; key-0 succeeds initially, then both @@ -399,13 +418,14 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { }, }, expectedRequestCount: 3, - expectedSeenKeys: []string{"k0", "k0", "k1"}, + expectedSeenKeys: []string{"k0-long-key", "k0-long-key", "k1-long-key"}, expectedBodyContains: "all configured keys are rate-limited", expectedErr: true, expectedKeyStates: []keypool.KeyState{ keypool.KeyStateTemporary, keypool.KeyStateTemporary, }, + expectedCredentialHint: utils.MaskSecret("k1-long-key"), }, } @@ -439,7 +459,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { })) t.Cleanup(upstream.Close) - pool, err := keypool.New([]string{"k0", "k1"}, quartz.NewMock(t)) + pool, err := keypool.New([]string{"k0-long-key", "k1-long-key"}, quartz.NewMock(t)) require.NoError(t, err) cfg := config.OpenAI{ @@ -489,6 +509,7 @@ func TestStreamingResponsesInterceptor_AgenticLoopFailover(t *testing.T) { assert.Equal(t, tc.expectedRequestCount, requestCount.Load(), "upstream request count") body := w.Body.String() assert.Contains(t, body, tc.expectedBodyContains, "response body") + assert.Equal(t, tc.expectedCredentialHint, interceptor.Credential().Hint, "credential hint") seenKeysMu.Lock() defer seenKeysMu.Unlock() diff --git a/aibridge/keypool/keymark.go b/aibridge/keypool/keymark.go index 9b00bb400a..9dfedb3e44 100644 --- a/aibridge/keypool/keymark.go +++ b/aibridge/keypool/keymark.go @@ -5,7 +5,6 @@ import ( "net/http" "cdr.dev/slog/v3" - "github.com/coder/coder/v2/aibridge/utils" ) // MarkKeyOnStatus marks key based on a key-specific HTTP @@ -32,7 +31,7 @@ func MarkKeyOnStatus( if key.MarkTemporary(cooldown) { logger.Info(ctx, "key marked temporary", slog.F("provider", providerName), - slog.F("api_key_hint", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode), slog.F("cooldown", cooldown)) } @@ -41,7 +40,7 @@ func MarkKeyOnStatus( if key.MarkPermanent() { logger.Warn(ctx, "key marked permanent", slog.F("provider", providerName), - slog.F("api_key_hint", utils.MaskSecret(key.Value())), + slog.F("api_key_hint", key.Hint()), slog.F("status", statusCode)) } return true diff --git a/aibridge/keypool/keypool.go b/aibridge/keypool/keypool.go index 55d1712a93..e28ae78325 100644 --- a/aibridge/keypool/keypool.go +++ b/aibridge/keypool/keypool.go @@ -7,6 +7,7 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/aibridge/utils" "github.com/coder/quartz" ) @@ -116,6 +117,12 @@ func (k *Key) Value() string { return k.value } +// Hint returns a masked, identifiable fragment of the key, suitable +// for logs and persisted records. +func (k *Key) Hint() string { + return utils.MaskSecret(k.value) +} + // State returns the current state of the key, derived from its // permanent flag and cooldown deadline. func (k *Key) State() KeyState { diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go index 664183f82f..d053cce903 100644 --- a/aibridge/provider/anthropic.go +++ b/aibridge/provider/anthropic.go @@ -170,15 +170,10 @@ func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tr authHeaderName = "Authorization" credKind = intercept.CredentialKindBYOK credSecret = token - } else if cfg.KeyPool != nil { - // Centralized: use the first key as a placeholder hint. - // TODO(ssncferreira): record the actually-used key in - // the interception record to reflect failover. - if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil { - credSecret = key.Value() - } } - + // Centralized leaves credSecret empty: the hint is set by the + // failover loop on each key attempt and persisted at + // end-of-interception. cred := intercept.NewCredentialInfo(credKind, credSecret) var interceptor intercept.Interceptor diff --git a/aibridge/provider/anthropic_internal_test.go b/aibridge/provider/anthropic_internal_test.go index b3d89556a8..815a83ba03 100644 --- a/aibridge/provider/anthropic_internal_test.go +++ b/aibridge/provider/anthropic_internal_test.go @@ -257,7 +257,9 @@ func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { setHeaders: map[string]string{}, wantXApiKey: "test-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "t...y", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, { name: "Messages_BYOK_BearerToken_And_APIKey", diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go index 7a78069df6..88020b7eb2 100644 --- a/aibridge/provider/openai.go +++ b/aibridge/provider/openai.go @@ -143,14 +143,10 @@ func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, trace cfg.KeyPool = nil credKind = intercept.CredentialKindBYOK credSecret = token - } else if cfg.KeyPool != nil { - // Centralized: use the first key as a placeholder hint. - // TODO(ssncferreira): record the actually-used key in - // the interception record to reflect failover. - if key, keyPoolErr := cfg.KeyPool.Walker().Next(); keyPoolErr == nil { - credSecret = key.Value() - } } + // Centralized leaves credSecret empty: the hint is set by the + // failover loop on each key attempt and persisted at + // end-of-interception. cred := intercept.NewCredentialInfo(credKind, credSecret) path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) diff --git a/aibridge/provider/openai_internal_test.go b/aibridge/provider/openai_internal_test.go index e1afcc872c..1922d22c30 100644 --- a/aibridge/provider/openai_internal_test.go +++ b/aibridge/provider/openai_internal_test.go @@ -229,7 +229,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...ey", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, { name: "Responses_BYOK", @@ -249,7 +251,9 @@ func TestOpenAI_CreateInterceptor(t *testing.T) { setHeaders: map[string]string{}, wantAuthorization: "Bearer centralized-key", wantCredentialKind: intercept.CredentialKindCentralized, - wantCredentialHint: "ce...ey", + // Centralized hint is empty at CreateInterceptor; set + // by the key failover loop during ProcessRequest. + wantCredentialHint: "", }, // X-Api-Key should not appear in production since clients use Authorization, // but ensure it is stripped if it does arrive. diff --git a/aibridge/recorder/types.go b/aibridge/recorder/types.go index cd541eebd4..faa5713900 100644 --- a/aibridge/recorder/types.go +++ b/aibridge/recorder/types.go @@ -39,13 +39,20 @@ type InterceptionRecord struct { Client string UserAgent string CorrelatingToolCallID *string - CredentialKind string - CredentialHint string + // CredentialKind is always set: either BYOK or centralized. + CredentialKind string + // CredentialHint is only set for BYOK, where the key is known + // from the request. Centralized uses key failover, so the hint + // can only be determined at end-of-interception. + CredentialHint string } type InterceptionRecordEnded struct { ID string EndedAt time.Time + // CredentialHint is the hint observed at end-of-interception. + // Only applied to the DB row for centralized; ignored for BYOK. + CredentialHint string } type TokenUsageRecord struct { diff --git a/coderd/aibridged/proto/aibridged.pb.go b/coderd/aibridged/proto/aibridged.pb.go index c364aeda40..17fef851ea 100644 --- a/coderd/aibridged/proto/aibridged.pb.go +++ b/coderd/aibridged/proto/aibridged.pb.go @@ -216,8 +216,9 @@ type RecordInterceptionEndedRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID. - EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // UUID. + EndedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=ended_at,json=endedAt,proto3" json:"ended_at,omitempty"` + CredentialHint string `protobuf:"bytes,3,opt,name=credential_hint,json=credentialHint,proto3" json:"credential_hint,omitempty"` } func (x *RecordInterceptionEndedRequest) Reset() { @@ -266,6 +267,13 @@ func (x *RecordInterceptionEndedRequest) GetEndedAt() *timestamppb.Timestamp { return nil } +func (x *RecordInterceptionEndedRequest) GetCredentialHint() string { + if x != nil { + return x.CredentialHint + } + return "" +} + type RecordInterceptionEndedResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1295,249 +1303,252 @@ var file_coderd_aibridged_proto_aibridged_proto_rawDesc = []byte{ 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 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, 0x22, 0x67, 0x0a, 0x1e, 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, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, - 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x22, 0x21, 0x0a, - 0x1f, 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, - 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, - 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, - 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x90, 0x01, 0x0a, 0x1e, 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, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x12, 0x27, + 0x0a, 0x0f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x68, 0x69, 0x6e, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x61, 0x6c, 0x48, 0x69, 0x6e, 0x74, 0x22, 0x21, 0x0a, 0x1f, 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, 0x22, 0xe9, 0x03, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, - 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, - 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, - 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, - 0x65, 0x52, 0x65, 0x61, 0x64, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, - 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, - 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, - 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, - 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, - 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, - 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x2d, 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, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, + 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, + 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x6e, + 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x75, 0x74, + 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, + 0x52, 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x48, + 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2c, 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, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, + 0x64, 0x41, 0x74, 0x12, 0x35, 0x0a, 0x17, 0x63, 0x61, 0x63, 0x68, 0x65, 0x5f, 0x72, 0x65, 0x61, + 0x64, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x63, 0x61, 0x63, 0x68, 0x65, 0x52, 0x65, 0x61, 0x64, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x5f, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x63, 0x61, + 0x63, 0x68, 0x65, 0x57, 0x72, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, - 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1b, 0x0a, 0x19, 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, 0x22, 0x8f, 0x04, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, - 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, - 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, - 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, - 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, - 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, - 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, - 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, - 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, - 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 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, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, - 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, - 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, - 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, - 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, - 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, - 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, - 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, - 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, - 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0xb8, 0x02, 0x0a, 0x19, 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, 0x12, 0x27, - 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, - 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, - 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, - 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, - 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x2e, 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, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, - 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, - 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1a, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0xcb, 0x02, 0x0a, 0x18, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x50, 0x72, 0x6f, + 0x6d, 0x70, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, + 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, + 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x70, 0x72, 0x6f, 0x6d, 0x70, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 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, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x1a, 0x51, 0x0a, + 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, + 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0x1b, 0x0a, 0x19, 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, 0x22, 0x8f, 0x04, + 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x12, 0x0a, 0x04, + 0x74, 0x6f, 0x6f, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x6f, 0x6f, 0x6c, + 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x6e, 0x6a, 0x65, 0x63, 0x74, + 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x10, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0f, + 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x88, + 0x01, 0x01, 0x12, 0x47, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x08, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 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, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x63, + 0x61, 0x6c, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x6f, + 0x6f, 0x6c, 0x43, 0x61, 0x6c, 0x6c, 0x49, 0x64, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, - 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 0x52, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, + 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, + 0x19, 0x0a, 0x17, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x54, 0x6f, 0x6f, 0x6c, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x02, 0x0a, 0x19, 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, 0x22, 0x35, 0x0a, 0x1a, 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, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, - 0x22, 0xb2, 0x01, 0x0a, 0x1b, 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, 0x40, 0x0a, 0x10, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, - 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, - 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, - 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, - 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, - 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, - 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, - 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, - 0x24, 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, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, - 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, - 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, - 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 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, 0x12, 0x63, 0x0a, 0x0d, 0x61, - 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x3e, 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, - 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, - 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, - 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, - 0x32, 0x38, 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, 0x2e, 0x45, - 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 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, 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, 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, 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, - 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, + 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x74, 0x65, + 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x4a, 0x0a, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 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, 0x2e, + 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, + 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x41, 0x74, 0x1a, 0x51, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x1c, 0x0a, 0x1a, 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, 0x22, 0x35, 0x0a, 0x1a, 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, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xb2, 0x01, 0x0a, 0x1b, 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, + 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x10, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, + 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x0e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x51, 0x0a, 0x19, + 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x63, + 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, + 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x16, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x41, 0x75, 0x74, 0x68, 0x4d, 0x63, 0x70, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, + 0x85, 0x01, 0x0a, 0x0f, 0x4d, 0x43, 0x50, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x69, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0e, 0x74, 0x6f, 0x6f, 0x6c, 0x41, 0x6c, 0x6c, 0x6f, 0x77, 0x52, 0x65, 0x67, 0x65, 0x78, 0x12, + 0x26, 0x0a, 0x0f, 0x74, 0x6f, 0x6f, 0x6c, 0x5f, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x67, + 0x65, 0x78, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x6f, 0x6f, 0x6c, 0x44, 0x65, + 0x6e, 0x79, 0x52, 0x65, 0x67, 0x65, 0x78, 0x22, 0x72, 0x0a, 0x24, 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, 0x12, + 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15, 0x6d, 0x63, 0x70, 0x5f, + 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x12, 0x6d, 0x63, 0x70, 0x53, 0x65, 0x72, 0x76, + 0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x49, 0x64, 0x73, 0x22, 0xda, 0x02, 0x0a, 0x25, 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, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3e, 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, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x50, 0x0a, 0x06, 0x65, 0x72, + 0x72, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 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, 0x2e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3f, 0x0a, 0x11, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 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, 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, 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, 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, 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 cd61411516..08fceee676 100644 --- a/coderd/aibridged/proto/aibridged.proto +++ b/coderd/aibridged/proto/aibridged.proto @@ -58,6 +58,7 @@ message RecordInterceptionResponse {} message RecordInterceptionEndedRequest { string id = 1; // UUID. google.protobuf.Timestamp ended_at = 2; + string credential_hint = 3; } message RecordInterceptionEndedResponse {} diff --git a/coderd/aibridged/translator.go b/coderd/aibridged/translator.go index 2769ef0d89..6d251df0fe 100644 --- a/coderd/aibridged/translator.go +++ b/coderd/aibridged/translator.go @@ -45,8 +45,9 @@ func (t *recorderTranslation) RecordInterception(ctx context.Context, req *aibri func (t *recorderTranslation) RecordInterceptionEnded(ctx context.Context, req *aibridge.InterceptionRecordEnded) error { _, err := t.client.RecordInterceptionEnded(ctx, &proto.RecordInterceptionEndedRequest{ - Id: req.ID, - EndedAt: timestamppb.New(req.EndedAt), + Id: req.ID, + EndedAt: timestamppb.New(req.EndedAt), + CredentialHint: req.CredentialHint, }) return err } diff --git a/coderd/aibridgedserver/aibridgedserver.go b/coderd/aibridgedserver/aibridgedserver.go index c593b18f79..8dbaa10bfa 100644 --- a/coderd/aibridgedserver/aibridgedserver.go +++ b/coderd/aibridgedserver/aibridgedserver.go @@ -222,8 +222,9 @@ func (s *Server) RecordInterceptionEnded(ctx context.Context, in *proto.RecordIn } _, err = s.store.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intcID, - EndedAt: in.EndedAt.AsTime(), + ID: intcID, + EndedAt: in.EndedAt.AsTime(), + CredentialHint: in.CredentialHint, }) if err != nil { return nil, xerrors.Errorf("end interception: %w", err) diff --git a/coderd/aibridgedserver/aibridgedserver_test.go b/coderd/aibridgedserver/aibridgedserver_test.go index eb2f413e1e..9aeb082069 100644 --- a/coderd/aibridgedserver/aibridgedserver_test.go +++ b/coderd/aibridgedserver/aibridgedserver_test.go @@ -944,23 +944,26 @@ func TestRecordInterceptionEnded(t *testing.T) { { name: "ok", request: &proto.RecordInterceptionEndedRequest{ - Id: uuid.UUID{1}.String(), - EndedAt: timestamppb.Now(), + Id: uuid.UUID{1}.String(), + EndedAt: timestamppb.Now(), + CredentialHint: "sk-a...efgh", }, setupMocks: func(t *testing.T, db *dbmock.MockStore, req *proto.RecordInterceptionEndedRequest) { interceptionID, err := uuid.Parse(req.GetId()) assert.NoError(t, err, "parse interception UUID") db.EXPECT().UpdateAIBridgeInterceptionEnded(gomock.Any(), database.UpdateAIBridgeInterceptionEndedParams{ - ID: interceptionID, - EndedAt: req.EndedAt.AsTime(), + ID: interceptionID, + EndedAt: req.EndedAt.AsTime(), + CredentialHint: req.CredentialHint, }).Return(database.AIBridgeInterception{ - ID: interceptionID, - InitiatorID: uuid.UUID{2}, - Provider: "prov", - Model: "mod", - StartedAt: time.Now(), - EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true}, + ID: interceptionID, + InitiatorID: uuid.UUID{2}, + Provider: "prov", + Model: "mod", + StartedAt: time.Now(), + EndedAt: sql.NullTime{Time: req.EndedAt.AsTime(), Valid: true}, + CredentialHint: req.CredentialHint, }, nil) }, }, diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index edc1d710cb..416a2b7257 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -2002,8 +2002,9 @@ func AIBridgeInterception(t testing.TB, db database.Store, seed database.InsertA }) if endedAt != nil { interception, err = db.UpdateAIBridgeInterceptionEnded(genCtx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: interception.ID, - EndedAt: *endedAt, + ID: interception.ID, + EndedAt: *endedAt, + CredentialHint: takeFirst(seed.CredentialHint, ""), }) require.NoError(t, err, "insert aibridge interception") } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index f181e2e94b..cefe6a866e 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -9921,8 +9921,9 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) got, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: uuid.New(), - EndedAt: time.Now(), + ID: uuid.New(), + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", }) require.ErrorContains(t, err, "no rows in result set") require.EqualValues(t, database.AIBridgeInterception{}, got) @@ -9957,18 +9958,21 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { endedAt := time.Now() // Mark first interception as done updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intc0.ID, - EndedAt: endedAt, + ID: intc0.ID, + EndedAt: endedAt, + CredentialHint: "sk-a...efgh", }) require.NoError(t, err) require.EqualValues(t, updated.ID, intc0.ID) require.True(t, updated.EndedAt.Valid) require.WithinDuration(t, endedAt, updated.EndedAt.Time, 5*time.Second) + require.Equal(t, "sk-a...efgh", updated.CredentialHint) // Updating first interception again should fail updated, err = db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ - ID: intc0.ID, - EndedAt: endedAt.Add(time.Hour), + ID: intc0.ID, + EndedAt: endedAt.Add(time.Hour), + CredentialHint: "sk-a...efgh", }) require.ErrorIs(t, err, sql.ErrNoRows) @@ -9979,6 +9983,52 @@ func TestUpdateAIBridgeInterceptionEnded(t *testing.T) { require.False(t, got.EndedAt.Valid) } }) + + t.Run("CentralizedHintUpdated", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{ + ID: uuid.New(), + InitiatorID: user.ID, + Metadata: json.RawMessage("{}"), + CredentialKind: database.CredentialKindCentralized, + CredentialHint: "", + }) + require.NoError(t, err) + + updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ + ID: intc.ID, + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", + }) + require.NoError(t, err) + require.Equal(t, "sk-a...efgh", updated.CredentialHint) + }) + + t.Run("BYOKHintPreserved", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + + user := dbgen.User(t, db, database.User{}) + intc, err := db.InsertAIBridgeInterception(ctx, database.InsertAIBridgeInterceptionParams{ + ID: uuid.New(), + InitiatorID: user.ID, + Metadata: json.RawMessage("{}"), + CredentialKind: database.CredentialKindByok, + CredentialHint: "sk-u...byok", + }) + require.NoError(t, err) + + updated, err := db.UpdateAIBridgeInterceptionEnded(ctx, database.UpdateAIBridgeInterceptionEndedParams{ + ID: intc.ID, + EndedAt: time.Now(), + CredentialHint: "sk-a...efgh", + }) + require.NoError(t, err) + require.Equal(t, "sk-u...byok", updated.CredentialHint) + }) } func TestDeleteExpiredAPIKeys(t *testing.T) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d4cf7468f4..b49e757549 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2389,20 +2389,28 @@ func (q *sqlQuerier) ListAIBridgeUserPromptsByInterceptionIDs(ctx context.Contex const updateAIBridgeInterceptionEnded = `-- name: UpdateAIBridgeInterceptionEnded :one UPDATE aibridge_interceptions - SET ended_at = $1::timestamptz + SET ended_at = $1::timestamptz, + -- BYOK records its hint at the start of the interception. + -- Centralized uses key failover, so its hint is only known + -- at end-of-interception. + credential_hint = CASE + WHEN credential_kind = 'centralized' THEN $2::text + ELSE credential_hint + END WHERE - id = $2::uuid + id = $3::uuid AND ended_at IS NULL RETURNING id, initiator_id, provider, model, started_at, metadata, ended_at, api_key_id, client, thread_parent_id, thread_root_id, client_session_id, session_id, provider_name, credential_kind, credential_hint ` type UpdateAIBridgeInterceptionEndedParams struct { - EndedAt time.Time `db:"ended_at" json:"ended_at"` - ID uuid.UUID `db:"id" json:"id"` + EndedAt time.Time `db:"ended_at" json:"ended_at"` + CredentialHint string `db:"credential_hint" json:"credential_hint"` + ID uuid.UUID `db:"id" json:"id"` } func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error) { - row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.ID) + row := q.db.QueryRowContext(ctx, updateAIBridgeInterceptionEnded, arg.EndedAt, arg.CredentialHint, arg.ID) var i AIBridgeInterception err := row.Scan( &i.ID, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 7756c7086b..a1b49d25cd 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -8,7 +8,14 @@ RETURNING *; -- name: UpdateAIBridgeInterceptionEnded :one UPDATE aibridge_interceptions - SET ended_at = @ended_at::timestamptz + SET ended_at = @ended_at::timestamptz, + -- BYOK records its hint at the start of the interception. + -- Centralized uses key failover, so its hint is only known + -- at end-of-interception. + credential_hint = CASE + WHEN credential_kind = 'centralized' THEN @credential_hint::text + ELSE credential_hint + END WHERE id = @id::uuid AND ended_at IS NULL From 60f0bfe94c6ffba5c0b27012d4d4039aba585d82 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 29 May 2026 14:15:21 +0300 Subject: [PATCH 08/14] fix: update tailscale fork to fix goroutine leak in TestRun (#25838) Update the coder/tailscale fork to include the fix for goroutine leaks in measureHTTPLatency. The function creates an http.Transport per DERP probe for a single HTTP request but did not disable keep-alives, causing readLoop/writeLoop goroutines to persist after the function returns. These were detected by goleak in support/TestRun. The fork fix sets DisableKeepAlives: true on the transport. Closes PLAT-289 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index fc7e53722e..e13cebfcf3 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ replace github.com/tcnksm/go-httpstat => github.com/coder/go-httpstat v0.0.0-202 // There are a few minor changes we make to Tailscale that we're slowly upstreaming. Compare here: // https://github.com/tailscale/tailscale/compare/main...coder:tailscale:main -replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f +replace tailscale.com => github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399 // This is replaced to include // 1. a fix for a data race: c.f. https://github.com/tailscale/wireguard-go/pull/25 diff --git a/go.sum b/go.sum index 3c08d31834..e639f168f3 100644 --- a/go.sum +++ b/go.sum @@ -350,8 +350,8 @@ github.com/coder/serpent v0.15.0 h1:jobR7DnPsxzEMD0cRiailwlY+4v6HAPS/8emIgBpaIU= github.com/coder/serpent v0.15.0/go.mod h1:7OIvFBYMd+OqarMy5einBl8AtRr8LliopVU7pyrwucY= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788 h1:YoUSJ19E8AtuUFVYBpXuOD6a/zVP3rcxezNsoDseTUw= github.com/coder/ssh v0.0.0-20231128192721-70855dedb788/go.mod h1:aGQbuCLyhRLMzZF067xc84Lh7JDs1FKwCmF1Crl9dxQ= -github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f h1:gYivllu5CHhvRr4SM93zSQDj9cG2V+Pc0URTFy3fF/Y= -github.com/coder/tailscale v1.1.1-0.20260519043957-6f014ff9434f/go.mod h1:WTWP5ZNODDXHwWlQ1Jc2MFhqxu93pUs7lIy28Fd5a5E= +github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399 h1:4IhFSmu0DSfWrvmHCb8aXDjWqSEYoIDA1L7Ar82Dm84= +github.com/coder/tailscale v1.1.1-0.20260529105257-b7c5fc6e6399/go.mod h1:IatCC3hlq/ncu6DjZ+GJ/hNjSf5TmO+Xtc6B20k0q/c= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e h1:JNLPDi2P73laR1oAclY6jWzAbucf70ASAvf5mh2cME0= github.com/coder/terraform-config-inspect v0.0.0-20250107175719-6d06d90c630e/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= github.com/coder/terraform-provider-coder/v2 v2.18.0 h1:b60ixwf7pVPuiL0GkHZf+1mVj94/HZhCNpsfjAK34mI= From 4144eb3c4fb0b3d01e838675cf11c84788209c89 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Fri, 29 May 2026 12:55:16 +0100 Subject: [PATCH 09/14] fix(site/src/pages/AgentsPage): avoid stale live tail spacing (#25846) --- .../components/ChatConversation/LiveStreamTail.tsx | 8 +++++++- site/src/pages/AgentsPage/components/ChatPageContent.tsx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx index 93b2c5ce69..9d3d5da412 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.tsx @@ -71,7 +71,13 @@ export const LiveStreamTailContent = ({ } return ( -
+
{shouldRenderEmptyState && (

Start a conversation with your agent.

diff --git a/site/src/pages/AgentsPage/components/ChatPageContent.tsx b/site/src/pages/AgentsPage/components/ChatPageContent.tsx index 098d767070..68d2644603 100644 --- a/site/src/pages/AgentsPage/components/ChatPageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatPageContent.tsx @@ -106,7 +106,7 @@ export const ChatPageTimeline: FC = ({
From d0a51da0a93b642792b9db36be3653ddbaacac34 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 13:14:04 +0100 Subject: [PATCH 10/14] feat: classify provider_disabled 503 as non-retryable (#25800) Builds on top of https://github.com/coder/coder/pull/25794 Adds a new `provider_disabled` error classification in `chatd` with the corresponding plumbing to classify it as non-retryable. Also adds a story for how this particular error kind is displayed in the UI. --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/x/chatd/chaterror/classify.go | 8 ++ coderd/x/chatd/chaterror/classify_test.go | 81 +++++++++++++++++++ coderd/x/chatd/chaterror/message.go | 77 +++++++++--------- coderd/x/chatd/chaterror/signals.go | 3 + codersdk/chats.go | 20 ++--- docs/reference/api/chats.md | 14 ++-- docs/reference/api/schemas.md | 6 +- site/src/api/typesGenerated.ts | 2 + .../LiveStreamTail.stories.tsx | 34 ++++++++ .../ChatConversation/chatStatusHelpers.ts | 2 + .../AgentsPage/utils/usageLimitMessage.ts | 5 +- 13 files changed, 200 insertions(+), 64 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 835823ecf4..7bca7d7988 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -16498,7 +16498,8 @@ const docTemplate = `{ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -16509,7 +16510,8 @@ const docTemplate = `{ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index db7cbf6809..d641744117 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14848,7 +14848,8 @@ "auth", "config", "usage_limit", - "missing_key" + "missing_key", + "provider_disabled" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -14859,7 +14860,8 @@ "ChatErrorKindAuth", "ChatErrorKindConfig", "ChatErrorKindUsageLimit", - "ChatErrorKindMissingKey" + "ChatErrorKindMissingKey", + "ChatErrorKindProviderDisabled" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/x/chatd/chaterror/classify.go b/coderd/x/chatd/chaterror/classify.go index 73e50f083b..4bf28efd4f 100644 --- a/coderd/x/chatd/chaterror/classify.go +++ b/coderd/x/chatd/chaterror/classify.go @@ -195,6 +195,7 @@ func Classify(err error) ClassifiedError { } retryableHTTP2StreamReset, hasHTTP2StreamReset := classifyHTTP2StreamReset(err) + providerDisabledMatch := containsAny(lower, providerDisabledPatterns...) deadline := errors.Is(err, context.DeadlineExceeded) || strings.Contains(lower, "context deadline exceeded") overloadedMatch := statusCode == 529 || containsAny(lower, overloadedPatterns...) usageLimitMatch := containsAny(lower, usageLimitPatterns...) @@ -221,6 +222,8 @@ func Classify(err error) ClassifiedError { // over whatever HTTP status code the provider happened to use. // Strong auth still stays above config because bad credentials are // the root cause when both signals appear. + // Provider-disabled must precede timeout because disabled providers + // return 503, which matches the timeout rule. rules := []struct { match bool kind codersdk.ChatErrorKind @@ -251,6 +254,11 @@ func Classify(err error) ClassifiedError { kind: codersdk.ChatErrorKindRateLimit, retryable: true, }, + { + match: providerDisabledMatch, + kind: codersdk.ChatErrorKindProviderDisabled, + retryable: false, + }, { match: timeoutMatch && !configMatch, kind: codersdk.ChatErrorKindTimeout, diff --git a/coderd/x/chatd/chaterror/classify_test.go b/coderd/x/chatd/chaterror/classify_test.go index 8e1a9783c3..0e2e008bb8 100644 --- a/coderd/x/chatd/chaterror/classify_test.go +++ b/coderd/x/chatd/chaterror/classify_test.go @@ -2,6 +2,7 @@ package chaterror_test import ( "context" + "fmt" "io" "net/http" "strings" @@ -218,6 +219,85 @@ func TestClassify(t *testing.T) { StatusCode: 0, }, }, + // The next cases model the error that fantasy produces + // when aibridge's disabledProviderHandler returns a 503 + // plain-text sentinel. Fantasy sets Title from the HTTP + // status text and Message from the response body (including + // the trailing newline written by http.Error). + { + name: "ProviderDisabled503ClassifiesAsProviderDisabled", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "openai"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The OpenAI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "openai"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "openai", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabled503UnknownProvider", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "mycustomprovider"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "ProviderDisabledPlainErrorString", + err: xerrors.New(fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "anthropic")), + want: chaterror.ClassifiedError{ + Message: "The Anthropic provider has been disabled. Contact your Coder administrator.", + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "anthropic", + Retryable: false, + StatusCode: 0, + }, + }, + { + name: "ProviderDisabledBeatsTimeout503", + err: &fantasy.ProviderError{ + Title: fantasy.ErrorTitleForStatusCode(http.StatusServiceUnavailable), + Message: fmt.Sprintf("%s: AI provider %q is disabled\n", codersdk.ChatErrorKindProviderDisabled, "google"), + StatusCode: http.StatusServiceUnavailable, + }, + want: chaterror.ClassifiedError{ + Message: "The Google provider has been disabled. Contact your Coder administrator.", + Detail: fmt.Sprintf("%s: AI provider %q is disabled", codersdk.ChatErrorKindProviderDisabled, "google"), + Kind: codersdk.ChatErrorKindProviderDisabled, + Provider: "google", + Retryable: false, + StatusCode: 503, + }, + }, + { + name: "Generic503StillClassifiesAsTimeout", + err: &fantasy.ProviderError{ + Message: "service unavailable", + StatusCode: 503, + }, + want: chaterror.ClassifiedError{ + Message: "The AI provider is temporarily unavailable.", + Detail: "service unavailable", + Kind: codersdk.ChatErrorKindTimeout, + Provider: "", + Retryable: true, + StatusCode: 503, + }, + }, } for _, tt := range tests { @@ -363,6 +443,7 @@ func TestClassify_PatternCoverage(t *testing.T) { {name: "OperationInterruptedLiteral", err: "operation interrupted", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: false}, {name: "Status408", err: "status 408", wantKind: codersdk.ChatErrorKindTimeout, wantRetry: true}, {name: "Status500", err: "status 500", wantKind: codersdk.ChatErrorKindGeneric, wantRetry: true}, + {name: "ProviderDisabledLiteral", err: "provider_disabled", wantKind: codersdk.ChatErrorKindProviderDisabled, wantRetry: false}, } for _, tt := range tests { diff --git a/coderd/x/chatd/chaterror/message.go b/coderd/x/chatd/chaterror/message.go index 5257420061..fef3ba78fa 100644 --- a/coderd/x/chatd/chaterror/message.go +++ b/coderd/x/chatd/chaterror/message.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + stringutil "github.com/coder/coder/v2/coderd/util/strings" "github.com/coder/coder/v2/codersdk" ) @@ -16,60 +17,58 @@ func terminalMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: if !classified.Retryable && classified.StatusCode == 0 { return "The request timed out before it completed." } - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindUsageLimit: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "The usage quota for %s has been exceeded."+ " Check the billing and quota settings for the provider account.", - displayName, - ) + subject, + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( "Authentication with %s failed."+ " Check the API key and permissions.", - displayName, + subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s rejected the model configuration."+ " Check the selected model and provider settings.", subject, - ) + )) case codersdk.ChatErrorKindMissingKey: return "This conversation was started with an API key that is no longer available." + " Send your message again to continue." - + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) + return fmt.Sprintf( + "The %s provider has been disabled."+ + " Contact your Coder administrator.", + displayName, + ) default: if !classified.Retryable && classified.StatusCode == 0 { return "The chat request failed unexpectedly." } - return fmt.Sprintf("%s returned an unexpected error.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s returned an unexpected error.", subject)) } } @@ -85,41 +84,43 @@ func retryMessage(classified ClassifiedError) string { subject := providerSubject(classified.Provider) switch classified.Kind { case codersdk.ChatErrorKindOverloaded: - return fmt.Sprintf("%s is temporarily overloaded.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily overloaded.", subject)) case codersdk.ChatErrorKindRateLimit: - return fmt.Sprintf("%s is rate limiting requests.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is rate limiting requests.", subject)) case codersdk.ChatErrorKindTimeout: - return fmt.Sprintf("%s is temporarily unavailable.", subject) + return stringutil.Capitalize(fmt.Sprintf("%s is temporarily unavailable.", subject)) case codersdk.ChatErrorKindStartupTimeout: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s did not start responding in time.", subject, - ) + )) case codersdk.ChatErrorKindAuth: - displayName := providerDisplayName(classified.Provider) - if displayName == "" { - displayName = "the AI provider" - } return fmt.Sprintf( - "Authentication with %s failed.", displayName, + "Authentication with %s failed.", subject, ) case codersdk.ChatErrorKindConfig: - return fmt.Sprintf( + return stringutil.Capitalize(fmt.Sprintf( "%s rejected the model configuration.", subject, - ) + )) case codersdk.ChatErrorKindMissingKey: return "The API key for this conversation is no longer available." - default: + case codersdk.ChatErrorKindProviderDisabled: + displayName := providerDisplayName(classified.Provider) return fmt.Sprintf( - "%s returned an unexpected error.", subject, + "The %s provider has been disabled by an administrator.", + displayName, ) + default: + return stringutil.Capitalize(fmt.Sprintf( + "%s returned an unexpected error.", subject, + )) } } func providerSubject(provider string) string { - if displayName := providerDisplayName(provider); displayName != "" { + if displayName := providerDisplayName(provider); displayName != "AI" && displayName != "" { return displayName } - return "The AI provider" + return "the AI provider" } func providerDisplayName(provider string) string { @@ -141,7 +142,7 @@ func providerDisplayName(provider string) string { case "vercel": return "Vercel AI Gateway" default: - return "" + return "AI" } } diff --git a/coderd/x/chatd/chaterror/signals.go b/coderd/x/chatd/chaterror/signals.go index ebe6ff939b..8dad919127 100644 --- a/coderd/x/chatd/chaterror/signals.go +++ b/coderd/x/chatd/chaterror/signals.go @@ -4,6 +4,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/coder/coder/v2/aibridge" ) type providerHint struct { @@ -83,6 +85,7 @@ var ( } genericRetryablePatterns = []string{"server error", "internal server error"} interruptedPatterns = []string{"chat interrupted", "request interrupted", "operation interrupted"} + providerDisabledPatterns = []string{aibridge.ErrorCodeProviderDisabled} ) func extractStatusCode(lower string) int { diff --git a/codersdk/chats.go b/codersdk/chats.go index c6deeb35aa..bcf235f590 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1525,15 +1525,16 @@ type ChatStreamStatus struct { type ChatErrorKind string const ( - ChatErrorKindGeneric ChatErrorKind = "generic" - ChatErrorKindOverloaded ChatErrorKind = "overloaded" - ChatErrorKindRateLimit ChatErrorKind = "rate_limit" - ChatErrorKindTimeout ChatErrorKind = "timeout" - ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" - ChatErrorKindAuth ChatErrorKind = "auth" - ChatErrorKindConfig ChatErrorKind = "config" - ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" - ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindGeneric ChatErrorKind = "generic" + ChatErrorKindOverloaded ChatErrorKind = "overloaded" + ChatErrorKindRateLimit ChatErrorKind = "rate_limit" + ChatErrorKindTimeout ChatErrorKind = "timeout" + ChatErrorKindStartupTimeout ChatErrorKind = "startup_timeout" + ChatErrorKindAuth ChatErrorKind = "auth" + ChatErrorKindConfig ChatErrorKind = "config" + ChatErrorKindUsageLimit ChatErrorKind = "usage_limit" + ChatErrorKindMissingKey ChatErrorKind = "missing_key" + ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1548,6 +1549,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindConfig, ChatErrorKindUsageLimit, ChatErrorKindMissingKey, + ChatErrorKindProviderDisabled, } // ChatError represents a terminal chat error in persisted chat state or the diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 691b2a2bc5..f475d8482d 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -292,13 +292,13 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|---------------|---------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | -| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | -| `plan_mode` | `plan` | -| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | +| Property | Value(s) | +|---------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| `type` | `context-file`, `file`, `file-reference`, `reasoning`, `skill`, `source`, `text`, `tool-call`, `tool-result` | +| `plan_mode` | `plan` | +| `status` | `completed`, `error`, `paused`, `pending`, `requires_action`, `running`, `waiting` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cd5f603559..771657d3c0 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2681,9 +2681,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|---------------------------------------------------------------------------------------------------------------------| -| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|------------------------------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `generic`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `startup_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 74b6041f3b..8a465a3073 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1969,6 +1969,7 @@ export type ChatErrorKind = | "generic" | "missing_key" | "overloaded" + | "provider_disabled" | "rate_limit" | "startup_timeout" | "timeout" @@ -1980,6 +1981,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "generic", "missing_key", "overloaded", + "provider_disabled", "rate_limit", "startup_timeout", "timeout", diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx index b58a6b8ebe..32484ed15c 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/LiveStreamTail.stories.tsx @@ -288,6 +288,40 @@ export const TerminalStartupTimeoutError: Story = { }, }; +/** Disabled provider errors render an admin-oriented message without retry. */ +export const TerminalProviderDisabledError: Story = { + args: { + ...defaultArgs, + liveStatus: buildLiveStatus({ + streamError: { + kind: "provider_disabled", + message: + "The OpenAI provider has been disabled. Contact your Coder administrator.", + provider: "openai", + retryable: false, + statusCode: 503, + }, + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: /provider disabled/i }), + ).toBeVisible(); + expect( + canvas.getByText( + /the openai provider has been disabled.*contact your coder administrator/i, + ), + ).toBeVisible(); + expect(canvas.getByText(/^HTTP 503$/)).toBeVisible(); + // No retry or status link for administrative disablement. + expect(canvas.queryByText(/retrying/i)).not.toBeInTheDocument(); + expect( + canvas.queryByRole("link", { name: /status/i }), + ).not.toBeInTheDocument(); + }, +}; + /** Generic failures do not show usage or provider CTAs. */ export const GenericErrorDoesNotShowUsageAction: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 243296ea2c..d9ea6f6e59 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -44,6 +44,8 @@ export const getErrorTitle = ( return "Usage limit reached"; case "missing_key": return "Chat interrupted"; + case "provider_disabled": + return "Provider disabled"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index 1986da0227..1c7f10f00f 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -11,9 +11,8 @@ type UsageLimitData = Partial< /** * Typed classification for errors surfaced in the agent detail view. * - "usage_limit": the user hit a spending cap (409 + valid usage data). - * - other kinds come from normalized stream/provider failures such as - * "generic", "overloaded", "rate_limit", "timeout", - * "startup_timeout", "auth", and "config". + * - other kinds come from normalized stream/provider failures. + * See ChatErrorKind for the full set. */ export type ChatDetailError = { message: string; From 110210d7c96dbd5c843bd7d817104d521ea38407 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 29 May 2026 15:14:55 +0200 Subject: [PATCH 11/14] fix(coderd): block ai provider env key drift (#25849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, `SeedAIProvidersFromEnv` only hashed provider-level fields, so env var key changes were silently ignored once a provider already existed in the database. Include bearer keys and Bedrock credentials in the canonical drift hash, and cover multi-key, multi-provider cases so restarts now fail loudly when the configured credentials no longer match what is stored. When changing a key, you'll now see this in the server startup logs: ``` 2026-05-29 12:29:02.674 [info] api: Encountered an error running "coder server", see "coder server --help" for more information 2026-05-29 12:29:02.674 [info] api: error: create coder API: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/cli.(*RootCmd).Server.func2 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/cli/server.go:1015 2026-05-29 12:29:02.674 [info] api: - seed ai providers from env: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/enterprise/cli.(*RootCmd).Server.func1 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/enterprise/cli/server.go:187 2026-05-29 12:29:02.674 [info] api: - execute transaction: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/coderd/database.(*sqlQuerier).runTx 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/coderd/database/db.go:212 ---> 2026-05-29 12:29:02.674 [info] api: - AI provider "vercel" already exists in the database and differs from the current environment configuration; update the provider through the API or remove the CODER_AIBRIDGE_* env vars to stop seeding it: 2026-05-29 12:29:02.674 [info] api: github.com/coder/coder/v2/coderd.SeedAIProvidersFromEnv.func1 2026-05-29 12:29:02.674 [info] api: /home/coder/coder/coderd/ai_providers_migrate.go:139 2026-05-29 12:29:02.674 [info] api: slogjson: failed to write entry: io: read/write on closed pipe 2026-05-29 12:29:02.700 [info] dlv: Stop reason: exited 2026-05-29 12:29:02.825 [info] site:  ELIFECYCLE  Command failed. error: running command "develop": server did not become ready in 1m0s: main.waitForHealthy /home/coder/coder/scripts/develop/main.go:877 - context canceled ``` _This PR was generated with Coder Agents._ --- coderd/ai_providers_migrate.go | 50 +++++++++-- coderd/ai_providers_migrate_test.go | 134 +++++++++++++++++++++++----- 2 files changed, 157 insertions(+), 27 deletions(-) diff --git a/coderd/ai_providers_migrate.go b/coderd/ai_providers_migrate.go index 6bc99ad840..055877ecce 100644 --- a/coderd/ai_providers_migrate.go +++ b/coderd/ai_providers_migrate.go @@ -116,10 +116,21 @@ func SeedAIProvidersFromEnv( if err != nil { return xerrors.Errorf("decode existing settings for %q: %w", dp.Name, err) } + // Load existing bearer keys so the canonical hash + // includes credentials for comparison. + existingKeyRows, err := tx.GetAIProviderKeysByProviderID(sysCtx, existing.ID) + if err != nil { + return xerrors.Errorf("load existing keys for %q: %w", dp.Name, err) + } + existingKeys := make([]string, 0, len(existingKeyRows)) + for _, k := range existingKeyRows { + existingKeys = append(existingKeys, k.APIKey) + } existingDP := desiredAIProvider{ Type: existing.Type, BaseURL: existing.BaseUrl, Bedrock: existingSettings.Bedrock, + Keys: existingKeys, } existingHash := computeProviderHash(existingDP.canonical()) if existingHash == dp.Hash { @@ -196,18 +207,15 @@ func SeedAIProvidersFromEnv( // canonicalAIProvider is the shape we hash to detect drift between the // configured environment and the row stored in the database. The fields // we hash are exactly the operator-controllable inputs that affect -// runtime behavior. Credentials are intentionally NOT part of the hash -// so operators can rotate them via the API without forcing a server -// restart. This applies to both bearer API keys (stored in -// ai_provider_keys) and to Bedrock access key/secret pairs (stored in -// the settings blob because Bedrock authenticates via settings rather -// than a bearer token). +// runtime behavior, including credentials. +// // Model and SmallFastModel are excluded: they're tunables, and their // serpent defaults shift across releases. type canonicalAIProvider struct { Type string `json:"type"` BaseURL string `json:"base_url"` BedrockRegion string `json:"bedrock_region"` + KeysHash string `json:"keys_hash"` } // desiredAIProvider is a normalized provider description sourced from @@ -235,9 +243,39 @@ func (d desiredAIProvider) canonical() canonicalAIProvider { if d.Bedrock != nil { c.BedrockRegion = d.Bedrock.Region } + c.KeysHash = computeKeysHash(d.Keys, d.Bedrock) return c } +// computeKeysHash produces a deterministic hash over the bearer API +// keys and, for Bedrock providers, the access key and secret. +func computeKeysHash(bearerKeys []string, bedrock *codersdk.AIProviderBedrockSettings) string { + // Collect all credential material in a deterministic order. + // Bearer keys are sorted so reordering in env vars does not + // trigger a false-positive drift. + sorted := make([]string, len(bearerKeys)) + copy(sorted, bearerKeys) + slices.Sort(sorted) + + h := sha256.New() + for _, k := range sorted { + _, _ = h.Write([]byte(k)) + // Separator so "ab"+"c" != "a"+"bc". + _, _ = h.Write([]byte{0}) + } + if bedrock != nil { + if bedrock.AccessKey != nil { + _, _ = h.Write([]byte(*bedrock.AccessKey)) + } + _, _ = h.Write([]byte{0}) + if bedrock.AccessKeySecret != nil { + _, _ = h.Write([]byte(*bedrock.AccessKeySecret)) + } + _, _ = h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} + func computeProviderHash(c canonicalAIProvider) string { // json.Marshal is deterministic for structs because field order is // fixed by the struct definition. diff --git a/coderd/ai_providers_migrate_test.go b/coderd/ai_providers_migrate_test.go index d4a07bfdc2..89165002b0 100644 --- a/coderd/ai_providers_migrate_test.go +++ b/coderd/ai_providers_migrate_test.go @@ -91,21 +91,23 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { } require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - // Changing the API key alone does NOT count as drift: keys - // live in a separate table and operators rotate them via the - // API. Only changes to non-credential provider-level fields - // (base_url, type, Bedrock region/model) trip the drift check. + // Changing the API key counts as drift: keys are included + // in the canonical hash so operators notice when env-var + // credential changes are ignored by an existing provider. cfg.LegacyOpenAI.Key = serpent.String("sk-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - - // Changing the base URL is real drift. - cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2") err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) require.Error(t, err) require.Contains(t, err.Error(), "differs from the current environment configuration") + + // Changing the base URL is also real drift. + cfg.LegacyOpenAI.Key = serpent.String("sk-original") + cfg.LegacyOpenAI.BaseURL = serpent.String("https://api.openai.com/v2") + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") }) - t.Run("BedrockCredentialRotationIsNotDrift", func(t *testing.T) { + t.Run("BedrockCredentialChangeIsDrift", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -120,17 +122,20 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { } require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) - // Rotating the Bedrock access key and secret in env must NOT - // trip the drift check: they're credentials, equivalent to - // bearer API keys, and operators rotate them via the API. + // Rotating the Bedrock access key in env trips the drift + // check so operators know the change did not take effect. cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-rotated") cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") // Changing the Bedrock region (a non-credential field) is - // real drift. + // also real drift. + cfg.LegacyBedrock.AccessKey = serpent.String("AKIA-original") + cfg.LegacyBedrock.AccessKeySecret = serpent.String("secret-original") cfg.LegacyBedrock.Region = serpent.String("us-west-2") - err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) require.Error(t, err) require.Contains(t, err.Error(), "differs from the current environment configuration") }) @@ -293,6 +298,57 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Equal(t, "sk-ant-1", anKeys[0].APIKey) }) + t.Run("IndexedProvidersKeyDriftWithMultipleKeysAndProviders", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "openai", + Name: "primary-openai", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-openai-1", "sk-openai-2"}, + }, + { + Type: "anthropic", + Name: "primary-anthropic", + BaseURL: "https://api.anthropic.com/", + Keys: []string{"sk-ant-1", "sk-ant-2"}, + }, + }, + } + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + // Reordering keys must not count as drift. The canonical hash + // sorts keys before hashing, so equivalent key sets remain + // stable across restarts. + cfg.Providers[0].Keys = []string{"sk-openai-2", "sk-openai-1"} + cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-1"} + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + // Changing one key on one provider must block startup even + // when multiple providers are configured. + cfg.Providers[1].Keys = []string{"sk-ant-2", "sk-ant-rotated"} + err := coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") + require.Contains(t, err.Error(), `"primary-anthropic"`) + + oa, err := db.GetAIProviderByName(ctx, "primary-openai") + require.NoError(t, err) + oaKeys, err := db.GetAIProviderKeysByProviderID(ctx, oa.ID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"sk-openai-1", "sk-openai-2"}, []string{oaKeys[0].APIKey, oaKeys[1].APIKey}) + + an, err := db.GetAIProviderByName(ctx, "primary-anthropic") + require.NoError(t, err) + anKeys, err := db.GetAIProviderKeysByProviderID(ctx, an.ID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"sk-ant-1", "sk-ant-2"}, []string{anKeys[0].APIKey, anKeys[1].APIKey}) + }) + t.Run("BedrockIndexedProviderHasNoKeys", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) @@ -424,7 +480,7 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Empty(t, all, "expected no active rows after soft-delete + re-seed") }) - t.Run("ExistingKeysArePreserved", func(t *testing.T) { + t.Run("ExistingKeysBlockOnDrift", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) ctx := testutil.Context(t, testutil.WaitShort) @@ -440,15 +496,17 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { row, err := db.GetAIProviderByName(ctx, "openai") require.NoError(t, err) - // Operator rotates the env key. The seed must not duplicate - // keys on a row that already exists; the new key is only - // installed via the API/CRUD layer in this flow. + // Operator rotates the env key. The seed now blocks startup + // because the keys differ, alerting the operator. cfg.LegacyOpenAI.Key = serpent.String("sk-rotated") - require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + err = coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t)) + require.Error(t, err) + require.Contains(t, err.Error(), "differs from the current environment configuration") + // The original key is still in the database. keys, err := db.GetAIProviderKeysByProviderID(ctx, row.ID) require.NoError(t, err) - require.Len(t, keys, 1, "env reseed must not duplicate keys on existing rows") + require.Len(t, keys, 1) require.Equal(t, "sk-original", keys[0].APIKey) }) @@ -482,6 +540,40 @@ func TestSeedAIProvidersFromEnv(t *testing.T) { require.Len(t, all, 1, "duplicate indexed entries with matching hash must produce a single row") }) + t.Run("IndexedDuplicateNameMatchingHashDedupesReorderedKeys", func(t *testing.T) { + t.Parallel() + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitShort) + + // Key order should not affect the canonical hash. Reordered + // duplicates under the same name should still dedupe. + cfg := codersdk.AIBridgeConfig{ + Providers: []codersdk.AIProviderConfig{ + { + Type: "openai", + Name: "shared", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-1", "sk-2"}, + }, + { + Type: "openai", + Name: "shared", + BaseURL: "https://api.openai.com/v1", + Keys: []string{"sk-2", "sk-1"}, + }, + }, + } + require.NoError(t, coderd.SeedAIProvidersFromEnv(ctx, db, cfg, testLogger(t))) + + all, err := db.GetAIProviders(ctx, database.GetAIProvidersParams{}) + require.NoError(t, err) + require.Len(t, all, 1) + keys, err := db.GetAIProviderKeysByProviderID(ctx, all[0].ID) + require.NoError(t, err) + require.Len(t, keys, 2) + require.ElementsMatch(t, []string{"sk-1", "sk-2"}, []string{keys[0].APIKey, keys[1].APIKey}) + }) + t.Run("IndexedDuplicateNameMismatchingHashFails", func(t *testing.T) { t.Parallel() db, _ := dbtestutil.NewDB(t) From 9448624d2de386fd60c42e2a6677679877b6f997 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 29 May 2026 15:27:24 +0200 Subject: [PATCH 12/14] feat(site): add Opus 4.8 known model (#25839) --- .../ChatModelAdminPanel.stories.tsx | 6 ++--- .../knownModels/anthropic.test.ts | 18 +++++++++++-- .../knownModels/anthropic.ts | 25 ++++++++++++++++--- .../applyKnownModelDefaults.test.ts | 4 +-- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index ec422bca5e..09a5386369 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -1374,9 +1374,9 @@ export const AnthropicKnownModelHappyPath: Story = { await openKnownModelPopover(body); const options = await body.findAllByRole("option"); - await userEvent.click(findOptionByText(options, "claude-opus-4-7")); + await userEvent.click(findOptionByText(options, "claude-opus-4-8")); - await expectModelIdentifierValue(body, "claude-opus-4-7"); + await expectModelIdentifierValue(body, "claude-opus-4-8"); await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1000000"); await expandSection(body, "Advanced"); @@ -1858,7 +1858,7 @@ export const KnownModelAutoHidePopoverWhenNoMatches: Story = { name: /Model Identifier/i, }); await userEvent.click(input); - await expect(await body.findByText("Claude Opus 4.7")).toBeInTheDocument(); + await expect(await body.findByText("Claude Opus 4.8")).toBeInTheDocument(); await userEvent.clear(input); await userEvent.type(input, "claude-opus-4-5"); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts index 0ad417439c..2e1762d758 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.test.ts @@ -22,6 +22,7 @@ describe("anthropicKnownModels", () => { (knownModel) => knownModel.modelIdentifier, ), ).toEqual([ + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -31,7 +32,11 @@ describe("anthropicKnownModels", () => { }); it("declares Anthropic reasoning defaults by API support", () => { - for (const modelIdentifier of ["claude-opus-4-7", "claude-opus-4-6"]) { + for (const modelIdentifier of [ + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + ]) { const knownModel = requireAnthropicKnownModel(modelIdentifier); expect(knownModel.reasoningEffort).toBe("high"); @@ -54,6 +59,7 @@ describe("anthropicKnownModels", () => { expect( anthropicKnownModels.map((knownModel) => knownModel.modelIdentifier), ).toEqual([ + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", @@ -64,8 +70,16 @@ describe("anthropicKnownModels", () => { for (const knownModel of anthropicKnownModels) { expect(knownModel.provider).toBe("anthropic"); expect(knownModel.sourceMetadata.sourceName).toBe("models.dev"); - expect(knownModel.sourceMetadata.sourceRetrievedAt).toBe("2026-04-30"); + expect(knownModel.sourceMetadata.sourceRetrievedAt).not.toBe(""); expect(knownModel.sourceMetadata.lastUpdated).not.toBe(""); } + + expect( + requireAnthropicKnownModel("claude-opus-4-8").sourceMetadata, + ).toEqual({ + sourceName: "models.dev", + sourceRetrievedAt: "2026-05-29", + lastUpdated: "2026-05-28", + }); }); }); diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts index 297ddabe2c..985fb49f7e 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts @@ -10,14 +10,33 @@ import type { KnownModel } from "./types"; // catalog and should be reviewed when the catalog is refreshed. // // Reasoning configuration is split per model based on Anthropic API support: -// models that support adaptive thinking (Opus 4.7, Opus 4.6, Sonnet 4.6) -// carry `reasoningEffort`, which Coder maps to `thinking.type: "adaptive"` -// with the `effort` parameter. Models that do not (Haiku 4.5, Sonnet 4.5) +// models that support adaptive thinking (Opus 4.8, Opus 4.7, Opus 4.6, +// Sonnet 4.6) carry `reasoningEffort`, which Coder maps to +// `thinking.type: "adaptive"` with the `effort` parameter. Models that do not +// (Haiku 4.5, Sonnet 4.5) // carry `thinkingBudgetTokens` instead, which Coder maps to the legacy // `thinking.type: "enabled"` path with `budget_tokens`. Setting `effort` on // the legacy path produces an "adaptive thinking is not supported on this // model" HTTP 400 from Anthropic. export const anthropicKnownModels = [ + { + provider: "anthropic", + modelIdentifier: "claude-opus-4-8", + displayName: "Claude Opus 4.8", + aliases: [], + contextLimit: 1_000_000, + maxOutputTokens: 128_000, + reasoningEffort: "high", + inputCost: 5, + outputCost: 25, + cacheReadCost: 0.5, + cacheWriteCost: 6.25, + sourceMetadata: { + sourceName: "models.dev", + sourceRetrievedAt: "2026-05-29", + lastUpdated: "2026-05-28", + }, + }, { provider: "anthropic", modelIdentifier: "claude-opus-4-7", diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts index d325c53385..d537aa726c 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.test.ts @@ -294,7 +294,7 @@ describe("applyKnownModelDefaults", () => { values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), provider: "anthropic", - knownModel: requireKnownModel("anthropic", "claude-opus-4-7"), + knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); expect(getPath(result.values, "config.anthropic.effort")).toBe("high"); @@ -327,7 +327,7 @@ describe("applyKnownModelDefaults", () => { values: buildInitialModelFormValues(), initialValues: buildInitialModelFormValues(), provider: "anthropic", - knownModel: requireKnownModel("anthropic", "claude-opus-4-7"), + knownModel: requireKnownModel("anthropic", "claude-opus-4-8"), }); expect(getPath(result.values, "config.anthropic.sendReasoning")).toBe(""); From 1a91d3179366055b6f74b5b5940fcae1d9729abd Mon Sep 17 00:00:00 2001 From: Yevhenii Shcherbina Date: Fri, 29 May 2026 10:08:25 -0400 Subject: [PATCH 13/14] feat: add user AI budget override endpoints (#25439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements https://linear.app/codercom/issue/AIGOV-285 Follow the structure established in https://github.com/coder/coder/pull/25203 ## Summary Adds the `user_ai_budget_overrides` table and CRUD API at `/api/v2/users/{user}/ai/budget`. An override sets a custom per-user spend cap that supersedes group-budget resolution, attributing spend to a specific group. ## Schema ```sql CREATE TABLE user_ai_budget_overrides ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ## Membership lifecycle The membership invariant — a user must be a member of the attributed group, including when that group is "Everyone" — would naturally be expressed as a composite FK on `(user_id, group_id) → group_members_expanded(user_id, group_id)`. PostgreSQL doesn't allow foreign keys to reference views, so enforcement is split across two mechanisms: - **Write-time check.** A CHECK constraint on the table (`user_ai_budget_overrides_must_be_group_member`) calls a `STABLE` function `is_group_member(user_id, group_id)` that queries `group_members_expanded`. The view surfaces both regular group memberships and the implicit "Everyone" group memberships from `organization_members`. Any INSERT or UPDATE that violates the predicate is rejected with a Postgres `check_violation`, which the handler maps to a 400. `is_group_member` is defined as a general predicate, reusable by any future table that needs the same check. - **Cascade on removal.** Two `BEFORE DELETE` triggers handle membership loss: - `trigger_delete_user_ai_budget_overrides_on_group_member_delete` on `group_members` — covers regular group removals (admin action, OIDC sync). - `trigger_delete_user_ai_budget_overrides_on_org_member_delete` on `organization_members` — covers the "Everyone" group, whose membership lives in `organization_members`. The single-column FKs on `users(id)` and `groups(id)` remain to cascade on user or group deletion (those paths don't pass through `group_members`). ## Authorization The dbauthz layer gates each operation against the `User` and (for writes) `Group` resources: | Operation | User resource | Group resource | |-----------|----------------|----------------| | `GET` | `ActionRead` | — | | `PUT` | `ActionUpdate` | `ActionUpdate` | | `DELETE` | `ActionUpdate` | `ActionUpdate` | For `DELETE`, the dbauthz layer fetches the existing override first to learn the attributed `group_id`, then runs both checks. ### Role matrix | Role | GET | PUT | DELETE | |--------------|-----|-----|--------| | Owner | ✅ | ✅ | ✅ | | UserAdmin | ✅ | ✅ | ✅ | | OrgAdmin | ✅ | ❌ | ❌ | | OrgUserAdmin | ✅ | ❌ | ❌ | Internal discussion: https://codercom.slack.com/archives/C096PFVBZKN/p1779392747885359 ## Audit logs Audit logs will be addressed in a follow-up PR. --- coderd/apidoc/docs.go | 145 ++++++ coderd/apidoc/swagger.json | 131 ++++++ coderd/database/check_constraint.go | 1 + coderd/database/db2sdk/db2sdk.go | 10 + coderd/database/dbauthz/dbauthz.go | 53 +++ coderd/database/dbauthz/dbauthz_test.go | 30 ++ coderd/database/dbmetrics/querymetrics.go | 24 + coderd/database/dbmock/dbmock.go | 45 ++ coderd/database/dump.sql | 62 +++ coderd/database/foreign_key_constraint.go | 2 + .../000513_user_ai_budget_overrides.down.sql | 7 + .../000513_user_ai_budget_overrides.up.sql | 76 +++ .../000513_user_ai_budget_overrides.up.sql | 15 + coderd/database/models.go | 9 + coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 65 +++ coderd/database/queries/aicostcontrol.sql | 17 + coderd/database/unique_constraint.go | 1 + codersdk/aibridge.go | 68 +++ docs/reference/api/enterprise.md | 119 +++++ docs/reference/api/schemas.md | 38 ++ enterprise/coderd/aibridge.go | 118 +++++ enterprise/coderd/aibridge_test.go | 441 ++++++++++++++++++ enterprise/coderd/coderd.go | 11 + site/src/api/typesGenerated.ts | 19 + 25 files changed, 1510 insertions(+) create mode 100644 coderd/database/migrations/000513_user_ai_budget_overrides.down.sql create mode 100644 coderd/database/migrations/000513_user_ai_budget_overrides.up.sql create mode 100644 coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7bca7d7988..1bbb216b1a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9171,6 +9171,110 @@ const docTemplate = `{ ] } }, + "/api/v2/users/{user}/ai/budget": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get user AI budget override", + "operationId": "get-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Upsert user AI budget override", + "operationId": "upsert-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Upsert user AI budget override request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { + "tags": [ + "Enterprise" + ], + "summary": "Delete user AI budget override", + "operationId": "delete-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/appearance": { "get": { "produces": [ @@ -24663,6 +24767,23 @@ const docTemplate = `{ } } }, + "codersdk.UpsertUserAIBudgetOverrideRequest": { + "type": "object", + "required": [ + "group_id" + ], + "properties": { + "group_id": { + "description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.", + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer", + "minimum": 0 + } + } + }, "codersdk.UpsertWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -24817,6 +24938,30 @@ const docTemplate = `{ } } }, + "codersdk.UserAIBudgetOverride": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.UserActivity": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index d641744117..6f7224e972 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8132,6 +8132,98 @@ ] } }, + "/api/v2/users/{user}/ai/budget": { + "get": { + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get user AI budget override", + "operationId": "get-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "put": { + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Upsert user AI budget override", + "operationId": "upsert-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Upsert user AI budget override request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpsertUserAIBudgetOverrideRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserAIBudgetOverride" + } + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + }, + "delete": { + "tags": ["Enterprise"], + "summary": "Delete user AI budget override", + "operationId": "delete-user-ai-budget-override", + "parameters": [ + { + "type": "string", + "description": "User ID, username, or me", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ] + } + }, "/api/v2/users/{user}/appearance": { "get": { "produces": ["application/json"], @@ -22696,6 +22788,21 @@ } } }, + "codersdk.UpsertUserAIBudgetOverrideRequest": { + "type": "object", + "required": ["group_id"], + "properties": { + "group_id": { + "description": "GroupID is the group the user's spend is attributed to. The user must\nbe a member of this group.", + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer", + "minimum": 0 + } + } + }, "codersdk.UpsertWorkspaceAgentPortShareRequest": { "type": "object", "properties": { @@ -22829,6 +22936,30 @@ } } }, + "codersdk.UserAIBudgetOverride": { + "type": "object", + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "group_id": { + "type": "string", + "format": "uuid" + }, + "spend_limit_micros": { + "type": "integer" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, "codersdk.UserActivity": { "type": "object", "properties": { diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go index 5682341ef9..1c20622e58 100644 --- a/coderd/database/check_constraint.go +++ b/coderd/database/check_constraint.go @@ -44,6 +44,7 @@ const ( CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events + CheckUserAiBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides CheckUserAiProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys CheckUserSkillsContentSize CheckConstraint = "user_skills_content_size" // user_skills CheckUserSkillsDescriptionSize CheckConstraint = "user_skills_description_size" // user_skills diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 36b081b86b..bc93df7cd3 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1509,6 +1509,16 @@ func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget { } } +func UserAIBudgetOverride(o database.UserAiBudgetOverride) codersdk.UserAIBudgetOverride { + return codersdk.UserAIBudgetOverride{ + UserID: o.UserID, + GroupID: o.GroupID, + SpendLimitMicros: o.SpendLimitMicros, + CreatedAt: o.CreatedAt, + UpdatedAt: o.UpdatedAt, + } +} + func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset { var presets []codersdk.InvalidatedPreset for _, p := range invalidatedPresets { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 5e98dfcf30..a1a7497153 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2323,6 +2323,32 @@ func (q *querier) DeleteTask(ctx context.Context, arg database.DeleteTaskParams) return q.db.DeleteTask(ctx, arg) } +func (q *querier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + // Removing a user's AI budget override affects both the user (clearing + // their per-user spend cap) and the group it was attributed to. + u, err := q.db.GetUserByID(ctx, userID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil { + return database.UserAiBudgetOverride{}, err + } + // Fetch the existing override to learn which group it attributes spend to, + // so we can authorize the caller against that group as well. + userOverride, err := q.db.GetUserAIBudgetOverride(ctx, userID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + g, err := q.db.GetGroupByID(ctx, userOverride.GroupID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil { + return database.UserAiBudgetOverride{}, err + } + return q.db.DeleteUserAIBudgetOverride(ctx, userID) +} + func (q *querier) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { @@ -4536,6 +4562,13 @@ func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, return q.db.GetUnexpiredLicenses(ctx) } +func (q *querier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + if _, err := q.GetUserByID(ctx, userID); err != nil { // AuthZ check + return database.UserAiBudgetOverride{}, err + } + return q.db.GetUserAIBudgetOverride(ctx, userID) +} + func (q *querier) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { @@ -8342,6 +8375,26 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error { return q.db.UpsertTemplateUsageStats(ctx) } +func (q *querier) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) { + // Setting a user's AI budget override affects both the user (their + // per-user spend cap) and the group (spend attribution). + u, err := q.db.GetUserByID(ctx, arg.UserID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, u); err != nil { + return database.UserAiBudgetOverride{}, err + } + g, err := q.db.GetGroupByID(ctx, arg.GroupID) + if err != nil { + return database.UserAiBudgetOverride{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, g); err != nil { + return database.UserAiBudgetOverride{}, err + } + return q.db.UpsertUserAIBudgetOverride(ctx, arg) +} + func (q *querier) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) { u, err := q.db.GetUserByID(ctx, arg.UserID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index cfc87d3ed4..f788fa71e2 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6475,6 +6475,36 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(g.ID).Asserts(g, policy.ActionUpdate).Returns(b) })) + s.Run("GetUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID}) + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes() + check.Args(user.ID).Asserts(user, policy.ActionRead).Returns(override) + })) + + s.Run("UpsertUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + group := testutil.Fake(s.T(), faker, database.Group{}) + override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID}) + arg := database.UpsertUserAIBudgetOverrideParams{UserID: user.ID, GroupID: group.ID, SpendLimitMicros: override.SpendLimitMicros} + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes() + dbm.EXPECT().UpsertUserAIBudgetOverride(gomock.Any(), arg).Return(override, nil).AnyTimes() + check.Args(arg).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override) + })) + + s.Run("DeleteUserAIBudgetOverride", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + user := testutil.Fake(s.T(), faker, database.User{}) + group := testutil.Fake(s.T(), faker, database.Group{}) + override := testutil.Fake(s.T(), faker, database.UserAiBudgetOverride{UserID: user.ID, GroupID: group.ID}) + dbm.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil).AnyTimes() + dbm.EXPECT().GetUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes() + dbm.EXPECT().GetGroupByID(gomock.Any(), group.ID).Return(group, nil).AnyTimes() + dbm.EXPECT().DeleteUserAIBudgetOverride(gomock.Any(), user.ID).Return(override, nil).AnyTimes() + check.Args(user.ID).Asserts(user, policy.ActionUpdate, group, policy.ActionUpdate).Returns(override) + })) + s.Run("GetAIProviderByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { provider := testutil.Fake(s.T(), faker, database.AIProvider{}) dbm.EXPECT().GetAIProviderByID(gomock.Any(), provider.ID).Return(provider, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c963d41d3f..e7120ec588 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -793,6 +793,14 @@ func (m queryMetricsStore) DeleteTask(ctx context.Context, arg database.DeleteTa return r0, r1 } +func (m queryMetricsStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + start := time.Now() + r0, r1 := m.s.DeleteUserAIBudgetOverride(ctx, userID) + m.queryLatencies.WithLabelValues("DeleteUserAIBudgetOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "DeleteUserAIBudgetOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error { start := time.Now() r0 := m.s.DeleteUserAIProviderKey(ctx, arg) @@ -2905,6 +2913,14 @@ func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database return r0, r1 } +func (m queryMetricsStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + start := time.Now() + r0, r1 := m.s.GetUserAIBudgetOverride(ctx, userID) + m.queryLatencies.WithLabelValues("GetUserAIBudgetOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserAIBudgetOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) { start := time.Now() r0, r1 := m.s.GetUserAIProviderKeyByProviderID(ctx, arg) @@ -6049,6 +6065,14 @@ func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error { return r0 } +func (m queryMetricsStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) { + start := time.Now() + r0, r1 := m.s.UpsertUserAIBudgetOverride(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertUserAIBudgetOverride").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertUserAIBudgetOverride").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) { start := time.Now() r0, r1 := m.s.UpsertUserAIProviderKey(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 75ac8f44ad..0f6799e638 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1349,6 +1349,21 @@ func (mr *MockStoreMockRecorder) DeleteTask(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTask", reflect.TypeOf((*MockStore)(nil).DeleteTask), ctx, arg) } +// DeleteUserAIBudgetOverride mocks base method. +func (m *MockStore) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserAIBudgetOverride", ctx, userID) + ret0, _ := ret[0].(database.UserAiBudgetOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteUserAIBudgetOverride indicates an expected call of DeleteUserAIBudgetOverride. +func (mr *MockStoreMockRecorder) DeleteUserAIBudgetOverride(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).DeleteUserAIBudgetOverride), ctx, userID) +} + // DeleteUserAIProviderKey mocks base method. func (m *MockStore) DeleteUserAIProviderKey(ctx context.Context, arg database.DeleteUserAIProviderKeyParams) error { m.ctrl.T.Helper() @@ -5445,6 +5460,21 @@ func (mr *MockStoreMockRecorder) GetUnexpiredLicenses(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUnexpiredLicenses", reflect.TypeOf((*MockStore)(nil).GetUnexpiredLicenses), ctx) } +// GetUserAIBudgetOverride mocks base method. +func (m *MockStore) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (database.UserAiBudgetOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserAIBudgetOverride", ctx, userID) + ret0, _ := ret[0].(database.UserAiBudgetOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserAIBudgetOverride indicates an expected call of GetUserAIBudgetOverride. +func (mr *MockStoreMockRecorder) GetUserAIBudgetOverride(ctx, userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).GetUserAIBudgetOverride), ctx, userID) +} + // GetUserAIProviderKeyByProviderID mocks base method. func (m *MockStore) GetUserAIProviderKeyByProviderID(ctx context.Context, arg database.GetUserAIProviderKeyByProviderIDParams) (database.UserAiProviderKey, error) { m.ctrl.T.Helper() @@ -11344,6 +11374,21 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), ctx) } +// UpsertUserAIBudgetOverride mocks base method. +func (m *MockStore) UpsertUserAIBudgetOverride(ctx context.Context, arg database.UpsertUserAIBudgetOverrideParams) (database.UserAiBudgetOverride, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertUserAIBudgetOverride", ctx, arg) + ret0, _ := ret[0].(database.UserAiBudgetOverride) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpsertUserAIBudgetOverride indicates an expected call of UpsertUserAIBudgetOverride. +func (mr *MockStoreMockRecorder) UpsertUserAIBudgetOverride(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertUserAIBudgetOverride", reflect.TypeOf((*MockStore)(nil).UpsertUserAIBudgetOverride), ctx, arg) +} + // UpsertUserAIProviderKey mocks base method. func (m *MockStore) UpsertUserAIProviderKey(ctx context.Context, arg database.UpsertUserAIProviderKeyParams) (database.UserAiProviderKey, error) { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index fad35755b9..82aa376d34 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -841,6 +841,42 @@ BEGIN END; $$; +CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.group_id; + RETURN OLD; +END; +$$; + +CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.organization_id; + RETURN OLD; +END; +$$; + +CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM group_members_expanded + WHERE user_id = NEW.user_id AND group_id = NEW.group_id + ) THEN + RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member'; + END IF; + RETURN NEW; +END; +$$; + CREATE FUNCTION enforce_user_secrets_per_user_limits() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -3137,6 +3173,17 @@ COMMENT ON TABLE usage_events_daily IS 'usage_events_daily is a daily rollup of COMMENT ON COLUMN usage_events_daily.day IS 'The date of the summed usage events, always in UTC.'; +CREATE TABLE user_ai_budget_overrides ( + user_id uuid NOT NULL, + group_id uuid NOT NULL, + spend_limit_micros bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT user_ai_budget_overrides_spend_limit_micros_check CHECK ((spend_limit_micros >= 0)) +); + +COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.'; + CREATE TABLE user_ai_provider_keys ( id uuid DEFAULT gen_random_uuid() NOT NULL, user_id uuid NOT NULL, @@ -3986,6 +4033,9 @@ ALTER TABLE ONLY usage_events_daily ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_ai_budget_overrides + ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id); + ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id); @@ -4467,6 +4517,12 @@ CREATE TRIGGER trigger_delete_group_members_on_org_member_delete BEFORE DELETE O CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_provider_app_tokens FOR EACH ROW EXECUTE FUNCTION delete_deleted_oauth2_provider_app_token_api_key(); +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete BEFORE DELETE ON group_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete(); + +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete BEFORE DELETE ON organization_members FOR EACH ROW EXECUTE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete(); + +CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership BEFORE INSERT OR UPDATE ON user_ai_budget_overrides FOR EACH ROW EXECUTE FUNCTION enforce_user_ai_budget_override_membership(); + CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted(); CREATE TRIGGER trigger_insert_organization_system_roles AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_organization_system_roles(); @@ -4792,6 +4848,12 @@ ALTER TABLE ONLY templates ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_ai_budget_overrides + ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY user_ai_budget_overrides + ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index a31346b423..5eeb24587a 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -105,6 +105,8 @@ const ( ForeignKeyTemplateVersionsTemplateID ForeignKeyConstraint = "template_versions_template_id_fkey" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_fkey FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE; ForeignKeyTemplatesCreatedBy ForeignKeyConstraint = "templates_created_by_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_created_by_fkey FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT; ForeignKeyTemplatesOrganizationID ForeignKeyConstraint = "templates_organization_id_fkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ForeignKeyUserAiBudgetOverridesGroupID ForeignKeyConstraint = "user_ai_budget_overrides_group_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + ForeignKeyUserAiBudgetOverridesUserID ForeignKeyConstraint = "user_ai_budget_overrides_user_id_fkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ForeignKeyUserAiProviderKeysAiProviderID ForeignKeyConstraint = "user_ai_provider_keys_ai_provider_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_ai_provider_id_fkey FOREIGN KEY (ai_provider_id) REFERENCES ai_providers(id) ON DELETE CASCADE; ForeignKeyUserAiProviderKeysAPIKeyKeyID ForeignKeyConstraint = "user_ai_provider_keys_api_key_key_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_api_key_key_id_fkey FOREIGN KEY (api_key_key_id) REFERENCES dbcrypt_keys(active_key_digest); ForeignKeyUserAiProviderKeysUserID ForeignKeyConstraint = "user_ai_provider_keys_user_id_fkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000513_user_ai_budget_overrides.down.sql b/coderd/database/migrations/000513_user_ai_budget_overrides.down.sql new file mode 100644 index 0000000000..1a1a8e2160 --- /dev/null +++ b/coderd/database/migrations/000513_user_ai_budget_overrides.down.sql @@ -0,0 +1,7 @@ +DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_org_member_delete ON organization_members; +DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_org_member_delete; +DROP TRIGGER IF EXISTS trigger_delete_user_ai_budget_overrides_on_group_member_delete ON group_members; +DROP FUNCTION IF EXISTS delete_user_ai_budget_overrides_on_group_member_delete; +DROP TRIGGER IF EXISTS trigger_enforce_user_ai_budget_override_membership ON user_ai_budget_overrides; +DROP FUNCTION IF EXISTS enforce_user_ai_budget_override_membership; +DROP TABLE IF EXISTS user_ai_budget_overrides CASCADE; diff --git a/coderd/database/migrations/000513_user_ai_budget_overrides.up.sql b/coderd/database/migrations/000513_user_ai_budget_overrides.up.sql new file mode 100644 index 0000000000..b1ab1cd9d2 --- /dev/null +++ b/coderd/database/migrations/000513_user_ai_budget_overrides.up.sql @@ -0,0 +1,76 @@ +CREATE TABLE user_ai_budget_overrides ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + -- Spend limit applied to the user, in micro-units (1 unit = 1,000,000). + spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + -- The membership invariant (user must be a member of the attributed + -- group, including when that group is "Everyone") would naturally be + -- a composite FK to group_members_expanded, but PostgreSQL does not + -- allow FKs to views. It's enforced instead by a write-time trigger + -- on this table and removal-time triggers on the underlying + -- membership tables. +); + +COMMENT ON TABLE user_ai_budget_overrides IS 'Per-user AI spend override that supersedes group budget resolution.'; + +-- Write-time membership check. Reads from group_members_expanded so +-- the "Everyone" group (whose membership lives in organization_members) +-- is correctly handled. Raises check_violation with a constraint name +-- so callers can match it via database.IsCheckViolation in Go. +CREATE FUNCTION enforce_user_ai_budget_override_membership() RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM group_members_expanded + WHERE user_id = NEW.user_id AND group_id = NEW.group_id + ) THEN + RAISE EXCEPTION 'user % is not a member of group %', NEW.user_id, NEW.group_id + USING ERRCODE = 'check_violation', + CONSTRAINT = 'user_ai_budget_overrides_must_be_group_member'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_enforce_user_ai_budget_override_membership + BEFORE INSERT OR UPDATE ON user_ai_budget_overrides + FOR EACH ROW +EXECUTE PROCEDURE enforce_user_ai_budget_override_membership(); + +-- When a user is removed from a regular group (any group except +-- "Everyone"), delete any override attributed to that group. +CREATE FUNCTION delete_user_ai_budget_overrides_on_group_member_delete() RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.group_id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_group_member_delete + BEFORE DELETE ON group_members + FOR EACH ROW +EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_group_member_delete(); + +-- When a user is removed from an organization, delete any override +-- attributed to that organization's "Everyone" group (which has +-- id == organization_id). +CREATE FUNCTION delete_user_ai_budget_overrides_on_org_member_delete() RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + DELETE FROM user_ai_budget_overrides + WHERE user_id = OLD.user_id AND group_id = OLD.organization_id; + RETURN OLD; +END; +$$; + +CREATE TRIGGER trigger_delete_user_ai_budget_overrides_on_org_member_delete + BEFORE DELETE ON organization_members + FOR EACH ROW +EXECUTE PROCEDURE delete_user_ai_budget_overrides_on_org_member_delete(); diff --git a/coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql b/coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql new file mode 100644 index 0000000000..787b808b7d --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000513_user_ai_budget_overrides.up.sql @@ -0,0 +1,15 @@ +-- Seed a group_members row so the override below references a real +-- membership. +INSERT INTO group_members ( + user_id, + group_id +) VALUES + ('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1') +ON CONFLICT DO NOTHING; + +INSERT INTO user_ai_budget_overrides ( + user_id, + group_id, + spend_limit_micros +) VALUES + ('30095c71-380b-457a-8995-97b8ee6e5307', 'bb640d07-ca8a-4869-b6bc-ae61ebb2fda1', 500000000); diff --git a/coderd/database/models.go b/coderd/database/models.go index 6149e12a10..ebfaa7a051 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -5730,6 +5730,15 @@ type User struct { ChatSpendLimitMicros sql.NullInt64 `db:"chat_spend_limit_micros" json:"chat_spend_limit_micros"` } +// Per-user AI spend override that supersedes group budget resolution. +type UserAiBudgetOverride struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` + SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + // User-owned API keys associated with AI providers. These keys are used only when BYOK is enabled. type UserAiProviderKey struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 0b3d565b69..a6c8f3e7db 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -198,6 +198,7 @@ type sqlcQuerier interface { DeleteTailnetPeer(ctx context.Context, arg DeleteTailnetPeerParams) (DeleteTailnetPeerRow, error) DeleteTailnetTunnel(ctx context.Context, arg DeleteTailnetTunnelParams) (DeleteTailnetTunnelRow, error) DeleteTask(ctx context.Context, arg DeleteTaskParams) (uuid.UUID, error) + DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) DeleteUserAIProviderKey(ctx context.Context, arg DeleteUserAIProviderKeyParams) error DeleteUserAIProviderKeysByProviderID(ctx context.Context, aiProviderID uuid.UUID) error DeleteUserChatCompactionThreshold(ctx context.Context, arg DeleteUserChatCompactionThresholdParams) error @@ -738,6 +739,7 @@ type sqlcQuerier interface { // inclusive. GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error) GetUnexpiredLicenses(ctx context.Context) ([]License, error) + GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAiProviderKey, error) // GetUserAIProviderKeys is used by dbcrypt key rotation. Request paths should use // user-scoped lookups instead of this bulk accessor. @@ -1407,6 +1409,7 @@ type sqlcQuerier interface { // used to store the data, and the minutes are summed for each user and template // combination. The result is stored in the template_usage_stats table. UpsertTemplateUsageStats(ctx context.Context) error + UpsertUserAIBudgetOverride(ctx context.Context, arg UpsertUserAIBudgetOverrideParams) (UserAiBudgetOverride, error) // UpsertUserAIProviderKey preserves the original id and created_at when the // user/provider pair already exists. On conflict, callers provide id and // created_at for the insert path only. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b49e757549..dc646121dc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2449,6 +2449,23 @@ func (q *sqlQuerier) DeleteGroupAIBudget(ctx context.Context, groupID uuid.UUID) return i, err } +const deleteUserAIBudgetOverride = `-- name: DeleteUserAIBudgetOverride :one +DELETE FROM user_ai_budget_overrides WHERE user_id = $1 RETURNING user_id, group_id, spend_limit_micros, created_at, updated_at +` + +func (q *sqlQuerier) DeleteUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) { + row := q.db.QueryRowContext(ctx, deleteUserAIBudgetOverride, userID) + var i UserAiBudgetOverride + err := row.Scan( + &i.UserID, + &i.GroupID, + &i.SpendLimitMicros, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getAIModelPriceByProviderModel = `-- name: GetAIModelPriceByProviderModel :one SELECT provider, model, input_price, output_price, cache_read_price, cache_write_price, created_at, updated_at FROM ai_model_prices @@ -2494,6 +2511,25 @@ func (q *sqlQuerier) GetGroupAIBudget(ctx context.Context, groupID uuid.UUID) (G return i, err } +const getUserAIBudgetOverride = `-- name: GetUserAIBudgetOverride :one +SELECT user_id, group_id, spend_limit_micros, created_at, updated_at +FROM user_ai_budget_overrides +WHERE user_id = $1 +` + +func (q *sqlQuerier) GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAiBudgetOverride, error) { + row := q.db.QueryRowContext(ctx, getUserAIBudgetOverride, userID) + var i UserAiBudgetOverride + err := row.Scan( + &i.UserID, + &i.GroupID, + &i.SpendLimitMicros, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const upsertAIModelPrices = `-- name: UpsertAIModelPrices :exec INSERT INTO ai_model_prices ( provider, model, input_price, output_price, cache_read_price, cache_write_price @@ -2548,6 +2584,35 @@ func (q *sqlQuerier) UpsertGroupAIBudget(ctx context.Context, arg UpsertGroupAIB return i, err } +const upsertUserAIBudgetOverride = `-- name: UpsertUserAIBudgetOverride :one +INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros) +VALUES ($1, $2, $3) +ON CONFLICT (user_id) DO UPDATE SET + group_id = EXCLUDED.group_id, + spend_limit_micros = EXCLUDED.spend_limit_micros, + updated_at = NOW() +RETURNING user_id, group_id, spend_limit_micros, created_at, updated_at +` + +type UpsertUserAIBudgetOverrideParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` + SpendLimitMicros int64 `db:"spend_limit_micros" json:"spend_limit_micros"` +} + +func (q *sqlQuerier) UpsertUserAIBudgetOverride(ctx context.Context, arg UpsertUserAIBudgetOverrideParams) (UserAiBudgetOverride, error) { + row := q.db.QueryRowContext(ctx, upsertUserAIBudgetOverride, arg.UserID, arg.GroupID, arg.SpendLimitMicros) + var i UserAiBudgetOverride + err := row.Scan( + &i.UserID, + &i.GroupID, + &i.SpendLimitMicros, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one SELECT COUNT(*) diff --git a/coderd/database/queries/aicostcontrol.sql b/coderd/database/queries/aicostcontrol.sql index 6740b2568c..188ec7357e 100644 --- a/coderd/database/queries/aicostcontrol.sql +++ b/coderd/database/queries/aicostcontrol.sql @@ -40,3 +40,20 @@ RETURNING *; -- name: DeleteGroupAIBudget :one DELETE FROM group_ai_budgets WHERE group_id = @group_id RETURNING *; + +-- name: GetUserAIBudgetOverride :one +SELECT * +FROM user_ai_budget_overrides +WHERE user_id = @user_id; + +-- name: UpsertUserAIBudgetOverride :one +INSERT INTO user_ai_budget_overrides (user_id, group_id, spend_limit_micros) +VALUES (@user_id, @group_id, @spend_limit_micros) +ON CONFLICT (user_id) DO UPDATE SET + group_id = EXCLUDED.group_id, + spend_limit_micros = EXCLUDED.spend_limit_micros, + updated_at = NOW() +RETURNING *; + +-- name: DeleteUserAIBudgetOverride :one +DELETE FROM user_ai_budget_overrides WHERE user_id = @user_id RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 8ef517a9cb..3d5e5dabcf 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -97,6 +97,7 @@ const ( UniqueTemplatesPkey UniqueConstraint = "templates_pkey" // ALTER TABLE ONLY templates ADD CONSTRAINT templates_pkey PRIMARY KEY (id); UniqueUsageEventsDailyPkey UniqueConstraint = "usage_events_daily_pkey" // ALTER TABLE ONLY usage_events_daily ADD CONSTRAINT usage_events_daily_pkey PRIMARY KEY (day, event_type); UniqueUsageEventsPkey UniqueConstraint = "usage_events_pkey" // ALTER TABLE ONLY usage_events ADD CONSTRAINT usage_events_pkey PRIMARY KEY (id); + UniqueUserAiBudgetOverridesPkey UniqueConstraint = "user_ai_budget_overrides_pkey" // ALTER TABLE ONLY user_ai_budget_overrides ADD CONSTRAINT user_ai_budget_overrides_pkey PRIMARY KEY (user_id); UniqueUserAiProviderKeysPkey UniqueConstraint = "user_ai_provider_keys_pkey" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_pkey PRIMARY KEY (id); UniqueUserAiProviderKeysUserIDAiProviderIDKey UniqueConstraint = "user_ai_provider_keys_user_id_ai_provider_id_key" // ALTER TABLE ONLY user_ai_provider_keys ADD CONSTRAINT user_ai_provider_keys_user_id_ai_provider_id_key UNIQUE (user_id, ai_provider_id); UniqueUserConfigsPkey UniqueConstraint = "user_configs_pkey" // ALTER TABLE ONLY user_configs ADD CONSTRAINT user_configs_pkey PRIMARY KEY (user_id, key); diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index 9bb7df0aac..d04359acb3 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -435,3 +435,71 @@ func (c *Client) DeleteGroupAIBudget(ctx context.Context, group uuid.UUID) error } return nil } + +type UserAIBudgetOverride struct { + UserID uuid.UUID `json:"user_id" format:"uuid"` + GroupID uuid.UUID `json:"group_id" format:"uuid"` + SpendLimitMicros int64 `json:"spend_limit_micros"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + UpdatedAt time.Time `json:"updated_at" format:"date-time"` +} + +type UpsertUserAIBudgetOverrideRequest struct { + // GroupID is the group the user's spend is attributed to. The user must + // be a member of this group. + GroupID uuid.UUID `json:"group_id" format:"uuid" validate:"required"` + SpendLimitMicros int64 `json:"spend_limit_micros" validate:"gte=0"` +} + +// UserAIBudgetOverride returns the AI spend budget override configured for the given user. +func (c *Client) UserAIBudgetOverride(ctx context.Context, user uuid.UUID) (UserAIBudgetOverride, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + nil, + ) + if err != nil { + return UserAIBudgetOverride{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UserAIBudgetOverride{}, ReadBodyAsError(res) + } + var resp UserAIBudgetOverride + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// UpsertUserAIBudgetOverride creates or updates the AI spend budget override for the given user. +func (c *Client) UpsertUserAIBudgetOverride(ctx context.Context, user uuid.UUID, req UpsertUserAIBudgetOverrideRequest) (UserAIBudgetOverride, error) { + res, err := c.Request(ctx, http.MethodPut, + fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + req, + ) + if err != nil { + return UserAIBudgetOverride{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return UserAIBudgetOverride{}, ReadBodyAsError(res) + } + var resp UserAIBudgetOverride + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// DeleteUserAIBudgetOverride removes the AI spend budget override for the given user. +func (c *Client) DeleteUserAIBudgetOverride(ctx context.Context, user uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, + fmt.Sprintf("/api/v2/users/%s/ai/budget", user.String()), + nil, + ) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md index eca72025c0..ed1ce268e7 100644 --- a/docs/reference/api/enterprise.md +++ b/docs/reference/api/enterprise.md @@ -3418,6 +3418,125 @@ curl -X POST http://coder-server:8080/api/v2/templates/{template}/prebuilds/inva To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user AI budget override + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/ai/budget \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /api/v2/users/{user}/ai/budget` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0, + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAIBudgetOverride](schemas.md#codersdkuseraibudgetoverride) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Upsert user AI budget override + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/ai/budget \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /api/v2/users/{user}/ai/budget` + +> Body parameter + +```json +{ + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0 +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|----------------------------------------------------------------------------------------------------|----------|----------------------------------------| +| `user` | path | string | true | User ID, username, or me | +| `body` | body | [codersdk.UpsertUserAIBudgetOverrideRequest](schemas.md#codersdkupsertuseraibudgetoverriderequest) | true | Upsert user AI budget override request | + +### Example responses + +> 200 Response + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0, + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserAIBudgetOverride](schemas.md#codersdkuseraibudgetoverride) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Delete user AI budget override + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/users/{user}/ai/budget \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /api/v2/users/{user}/ai/budget` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------|----------|--------------------------| +| `user` | path | string | true | User ID, username, or me | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get user quiet hours schedule ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 771657d3c0..ea8f19c4bf 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -13601,6 +13601,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| |----------------------|---------|----------|--------------|-------------| | `spend_limit_micros` | integer | false | | | +## codersdk.UpsertUserAIBudgetOverrideRequest + +```json +{ + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|---------|----------|--------------|---------------------------------------------------------------------------------------------------| +| `group_id` | string | true | | Group ID is the group the user's spend is attributed to. The user must be a member of this group. | +| `spend_limit_micros` | integer | false | | | + ## codersdk.UpsertWorkspaceAgentPortShareRequest ```json @@ -13730,6 +13746,28 @@ If the schedule is empty, the user will be updated to use the default schedule.| |----------|-----------------------| | `status` | `active`, `suspended` | +## codersdk.UserAIBudgetOverride + +```json +{ + "created_at": "2019-08-24T14:15:22Z", + "group_id": "306db4e0-7449-4501-b76f-075576fe2d8f", + "spend_limit_micros": 0, + "updated_at": "2019-08-24T14:15:22Z", + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------------|---------|----------|--------------|-------------| +| `created_at` | string | false | | | +| `group_id` | string | false | | | +| `spend_limit_micros` | integer | false | | | +| `updated_at` | string | false | | | +| `user_id` | string | false | | | + ## codersdk.UserActivity ```json diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index 9773dba352..8a220760de 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -43,6 +43,11 @@ const ( // reference a valid resource in the expected scope. var errInvalidCursor = xerrors.New("invalid pagination cursor") +// This name is raised by a trigger function with USING CONSTRAINT. +// It is not a table CHECK constraint, so dbgen does not emit it in +// check_constraint.go. +const userAIBudgetOverridesMustBeGroupMemberConstraint database.CheckConstraint = "user_ai_budget_overrides_must_be_group_member" + // aibridgeHandler handles all aibridged-related endpoints. func aibridgeHandler(api *API, middlewares ...func(http.Handler) http.Handler) func(r chi.Router) { // Build the overload protection middleware chain for the aibridged handler. @@ -821,3 +826,116 @@ func (api *API) deleteGroupAIBudget(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNoContent) } + +// @Summary Get user AI budget override +// @ID get-user-ai-budget-override +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID, username, or me" +// @Success 200 {object} codersdk.UserAIBudgetOverride +// @Router /api/v2/users/{user}/ai/budget [get] +func (api *API) userAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + override, err := api.Database.GetUserAIBudgetOverride(ctx, user.ID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "get user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override)) +} + +// @Summary Upsert user AI budget override +// @ID upsert-user-ai-budget-override +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID, username, or me" +// @Param request body codersdk.UpsertUserAIBudgetOverrideRequest true "Upsert user AI budget override request" +// @Success 200 {object} codersdk.UserAIBudgetOverride +// @Router /api/v2/users/{user}/ai/budget [put] +func (api *API) upsertUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + var req codersdk.UpsertUserAIBudgetOverrideRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + // Look up the group first so a missing or forbidden group_id returns + // 404, distinct from the 400 "not a member" case handled below. + if _, err := api.Database.GetGroupByID(ctx, req.GroupID); err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + api.Logger.Error(ctx, "get group for user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + override, err := api.Database.UpsertUserAIBudgetOverride(ctx, database.UpsertUserAIBudgetOverrideParams{ + UserID: user.ID, + GroupID: req.GroupID, + SpendLimitMicros: req.SpendLimitMicros, + }) + // A trigger enforces that the user must be a member of the attributed + // group; it raises check_violation with this constraint name. Map + // the violation to a structured 400. + if database.IsCheckViolation(err, userAIBudgetOverridesMustBeGroupMemberConstraint) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "User is not a member of the referenced group.", + Validations: []codersdk.ValidationError{{ + Field: "group_id", + Detail: "user must be a member of this group", + }}, + }) + return + } + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "upsert user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserAIBudgetOverride(override)) +} + +// @Summary Delete user AI budget override +// @ID delete-user-ai-budget-override +// @Security CoderSessionToken +// @Tags Enterprise +// @Param user path string true "User ID, username, or me" +// @Success 204 +// @Router /api/v2/users/{user}/ai/budget [delete] +func (api *API) deleteUserAIBudgetOverride(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user := httpmw.UserParam(r) + + _, err := api.Database.DeleteUserAIBudgetOverride(ctx, user.ID) + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + api.Logger.Error(ctx, "delete user AI budget override", slog.Error(err)) + httpapi.InternalServerError(rw, err) + return + } + + rw.WriteHeader(http.StatusNoContent) +} diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 158f682842..1faadd1f53 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -2871,6 +2871,447 @@ func TestGroupAIBudget(t *testing.T) { }) } +func TestUserAIBudgetOverride(t *testing.T) { + t.Parallel() + + t.Run("Upsert/CreatesAndUpdates", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // First upsert creates the override. + newOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + require.Equal(t, targetUser.ID, newOverride.UserID) + require.Equal(t, group.ID, newOverride.GroupID) + require.EqualValues(t, 500_000_000, newOverride.SpendLimitMicros) + + // Second upsert updates the existing override. + updatedOverride, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 1_000_000_000, + }) + require.NoError(t, err) + require.EqualValues(t, 1_000_000_000, updatedOverride.SpendLimitMicros) + + // GET returns the latest value. + currentOverride, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err) + require.EqualValues(t, 1_000_000_000, currentOverride.SpendLimitMicros) + }) + + t.Run("Upsert/ReassignsGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, groupA := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // First upsert: attribute spend to groupA. + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: groupA.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + // Create groupB in the same org and add the target user. + groupB, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: "reassign-test-group-b", + }) + require.NoError(t, err) + _, err = adminClient.PatchGroup(ctx, groupB.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + // Reassign the override's attribution to groupB. + updated, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: groupB.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + require.Equal(t, groupB.ID, updated.GroupID, "upsert should change attributed group") + + // GET reflects the new group. + got, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err) + require.Equal(t, groupB.ID, got.GroupID, "GET should reflect new group") + }) + + t.Run("Upsert/EveryoneGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // The Everyone group has id == organization_id, and the target user + // is implicitly a member via organization_members rather than + // group_members. The membership trigger queries + // group_members_expanded (a UNION of both tables), so this case + // exercises the organization_members branch. + everyoneGroupID := targetUser.OrganizationIDs[0] + + override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: everyoneGroupID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err, "should be able to attribute override to Everyone group") + require.Equal(t, targetUser.ID, override.UserID) + require.Equal(t, everyoneGroupID, override.GroupID) + require.EqualValues(t, 500_000_000, override.SpendLimitMicros) + }) + + t.Run("Upsert/AcceptsZeroSpendLimit", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // 0 is a valid value: it blocks all spend for the user. + override, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 0, + }) + require.NoError(t, err) + require.EqualValues(t, 0, override.SpendLimitMicros) + }) + + t.Run("Upsert/RejectsNegativeSpend", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: -1, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("Upsert/RejectsUnknownGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // A group_id that doesn't exist (or that the caller can't see) + // is rejected by the visibility check before the membership check. + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: uuid.New(), + SpendLimitMicros: 500_000_000, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Upsert/RejectsNonMemberGroup", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + // Create a second group the target is NOT a member of. + outsiderGroup, err := adminClient.CreateGroup(ctx, targetUser.OrganizationIDs[0], codersdk.CreateGroupRequest{ + Name: "outsider-group", + }) + require.NoError(t, err) + + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: outsiderGroup.ID, + SpendLimitMicros: 500_000_000, + }) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) + }) + + t.Run("Get/AbsentReturns404", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Get/UnknownUserReturns404", func(t *testing.T) { + t.Parallel() + + adminClient, _, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UserAIBudgetOverride(ctx, uuid.New()) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Delete/RoundTrip", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, group := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err) + + require.NoError(t, adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID)) + + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) + + t.Run("Delete/AbsentReturns404", func(t *testing.T) { + t.Parallel() + + adminClient, targetUser, _ := setupUserAIBudgetOverrideTest(t) + ctx := testutil.Context(t, testutil.WaitLong) + + err := adminClient.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} + +// TestUserAIBudgetOverrideRoleAccess verifies the authz matrix for the roles +// expected to interact with user budget overrides: +// +// - Owner / UserAdmin: full CRUD. +// - OrgAdmin / OrgUserAdmin: read-only. Writes require ActionUpdate on the +// User resource (site-scoped), which neither role has. +// +//nolint:tparallel // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race. +func TestUserAIBudgetOverrideRoleAccess(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + }, + }, + }) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + orgAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgAdmin(owner.OrganizationID)) + orgUserAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.ScopedRoleOrgUserAdmin(owner.OrganizationID)) + + setupCtx := testutil.Context(t, testutil.WaitLong) + group, err := userAdminClient.CreateGroup(setupCtx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "role-access-group", + }) + require.NoError(t, err) + + cases := []struct { + Name string + Client *codersdk.Client + CanWrite bool + }{ + {Name: "Owner", Client: ownerClient, CanWrite: true}, + {Name: "UserAdmin", Client: userAdminClient, CanWrite: true}, + {Name: "OrgAdmin", Client: orgAdminClient, CanWrite: false}, + {Name: "OrgUserAdmin", Client: orgUserAdminClient, CanWrite: false}, + } + + //nolint:paralleltest // Subtests run sequentially: they share the same deployment and group, and parallel PatchGroup calls on the same group race. + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitLong) + + // Each case gets a fresh target user. + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + _, err := userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + upsertReq := codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + } + + if tc.CanWrite { + // Full CRUD lifecycle. + override, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq) + require.NoError(t, err, "PUT") + require.Equal(t, group.ID, override.GroupID) + + got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "GET") + require.EqualValues(t, 500_000_000, got.SpendLimitMicros) + + err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "DELETE") + } else { + // PUT rejected. + _, err := tc.Client.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "PUT") + + // Seed a row via UserAdmin so we can verify read access still works. + _, err = userAdminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, upsertReq) + require.NoError(t, err) + + // GET still works (all roles have ActionRead on User). + got, err := tc.Client.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "GET") + require.EqualValues(t, 500_000_000, got.SpendLimitMicros) + + // DELETE rejected. + err = tc.Client.DeleteUserAIBudgetOverride(ctx, targetUser.ID) + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), "DELETE") + } + }) + } +} + +// TestUserAIBudgetOverrideDeletedOnMembershipRemoval verifies that a per-user +// override is deleted automatically when the user loses membership in the +// attributed group. Two paths are exercised: +// +// - RegularGroup: membership stored in group_members; removed via +// PatchGroup with RemoveUsers. +// - EveryoneGroup: membership stored in organization_members; removed +// via DeleteOrganizationMember. +func TestUserAIBudgetOverrideDeletedOnMembershipRemoval(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + }, + }, + }) + adminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + + // "Regular group" means any group except "Everyone". + t.Run("RegularGroup", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + group, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "cascade-regular-group", + }) + require.NoError(t, err) + + _, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + + _, err = adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: group.ID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err, "set override") + + // Sanity-check the override exists. + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "override should exist before removal") + + _, err = adminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + RemoveUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err, "remove user from group") + + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), + "override should be deleted after user is removed from the attributed group") + }) + + t.Run("EveryoneGroup", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + + _, targetUser := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // The Everyone group has id == organization_id. + everyoneGroupID := owner.OrganizationID + + _, err := adminClient.UpsertUserAIBudgetOverride(ctx, targetUser.ID, codersdk.UpsertUserAIBudgetOverrideRequest{ + GroupID: everyoneGroupID, + SpendLimitMicros: 500_000_000, + }) + require.NoError(t, err, "set override") + + // Sanity-check the override exists. + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + require.NoError(t, err, "override should exist before removal") + + err = adminClient.DeleteOrganizationMember(ctx, owner.OrganizationID, targetUser.ID.String()) + require.NoError(t, err, "remove user from organization") + + _, err = adminClient.UserAIBudgetOverride(ctx, targetUser.ID) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode(), + "override should be deleted after user is removed from the organization") + }) +} + +// setupUserAIBudgetOverrideTest returns an Admin client, a target user, and a +// group the target user is a member of. +func setupUserAIBudgetOverrideTest(t *testing.T) (adminClient *codersdk.Client, targetUser codersdk.User, group codersdk.Group) { + t.Helper() + + dv := coderdtest.DeploymentValues(t) + dv.AI.BridgeConfig.Enabled = serpent.Bool(true) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{DeploymentValues: dv}, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAIBridge: 1, + }, + }, + }) + adminClient, _ = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin()) + _, targetUser = coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + ctx := testutil.Context(t, testutil.WaitLong) + g, err := adminClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "override-test-group", + }) + require.NoError(t, err) + g, err = adminClient.PatchGroup(ctx, g.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{targetUser.ID.String()}, + }) + require.NoError(t, err) + return adminClient, targetUser, g +} + // setupGroupAIBudgetTest returns an Admin client along with a newly created group inside it. func setupGroupAIBudgetTest(t *testing.T) (adminClient *codersdk.Client, group codersdk.Group) { t.Helper() diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2a55c6218f..33732eea3d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -596,6 +596,17 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Get("/", api.userQuietHoursSchedule) r.Put("/", api.putUserQuietHoursSchedule) }) + r.Route("/users/{user}/ai/budget", func(r chi.Router) { + // AI cost controls are a paid feature (AI Governance add-on). + r.Use( + api.RequireFeatureMW(codersdk.FeatureAIBridge), + apiKeyMiddleware, + httpmw.ExtractUserParam(options.Database), + ) + r.Get("/", api.userAIBudgetOverride) + r.Put("/", api.upsertUserAIBudgetOverride) + r.Delete("/", api.deleteUserAIBudgetOverride) + }) r.Route("/prebuilds", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8a465a3073..c4fae7a3d0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -9160,6 +9160,16 @@ export interface UpsertGroupAIBudgetRequest { readonly spend_limit_micros: number; } +// From codersdk/aibridge.go +export interface UpsertUserAIBudgetOverrideRequest { + /** + * GroupID is the group the user's spend is attributed to. The user must + * be a member of this group. + */ + readonly group_id: string; + readonly spend_limit_micros: number; +} + // From codersdk/workspaceagentportshare.go export interface UpsertWorkspaceAgentPortShareRequest { readonly agent_name: string; @@ -9204,6 +9214,15 @@ export interface User extends ReducedUser { readonly has_ai_seat: boolean; } +// From codersdk/aibridge.go +export interface UserAIBudgetOverride { + readonly user_id: string; + readonly group_id: string; + readonly spend_limit_micros: number; + readonly created_at: string; + readonly updated_at: string; +} + // From codersdk/chats.go /** * UserAIProviderKeyConfig is a provider summary from the current user's From 98d5e7948d88e5a4f0b906efe09e630c54b6d0e4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 29 May 2026 17:12:31 +0300 Subject: [PATCH 14/14] fix(coderd/autobuild): handle concurrent build number race in lifecycle executor (#25824) The lifecycle executor did not handle unique-violation errors from InsertWorkspaceBuild. When a concurrent actor (API handler, another lifecycle executor, or prebuilds reconciler) inserts a workspace build with the same build number, PostgreSQL returns a unique constraint violation on workspace_builds_workspace_id_build_number_key. The lifecycle executor treated this as a hard error, logging it and storing it in stats.Errors. The per-workspace advisory lock (pg_try_advisory_xact_lock) prevents two lifecycle executors from racing, but does not protect against races with the CreateWorkspaceBuild API handler or the prebuilds reconciler, which use different (or no) locking. Catch the specific unique-violation error after InTx returns (where the transaction is already rolled back) and clear it. The concurrent actor's build takes effect; the lifecycle executor treats the workspace as a no-op for this tick. Closes coder/internal#455 Closes PLAT-290 --- coderd/autobuild/lifecycle_executor.go | 17 ++++ coderd/autobuild/lifecycle_executor_test.go | 88 +++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 84fff375e0..5a141ce8cf 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -422,6 +422,23 @@ func (e *Executor) runOnce(t time.Time) Stats { Isolation: sql.LevelRepeatableRead, TxIdentifier: "lifecycle", }) + // A concurrent build (e.g. from the API or another lifecycle + // executor) may have already inserted a build with the same + // number. This is a benign race; the other actor's build + // will take effect. Clear the error so downstream checks + // (audit, notification, stats) treat this as a no-op. + if database.IsUniqueViolation(err, database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey) { + log.Info(e.ctx, "skipping workspace: concurrent build already inserted", slog.Error(err)) + err = nil + // Reset notification flags set before builder.Build. + // The build was rolled back, so this executor did not + // perform the transition. The concurrent actor handles + // both the build and any notifications. Without these + // resets, downstream code would send duplicate or + // incorrect notifications. + didAutoUpdate = false + shouldNotifyTaskPause = false + } if auditLog != nil { // If the transition didn't succeed then updating the workspace // to indicate dormant didn't either. diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 345647977d..89805429b9 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -4,10 +4,12 @@ import ( "context" "database/sql" "errors" + "sync/atomic" "testing" "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -160,6 +162,92 @@ func TestMultipleLifecycleExecutors(t *testing.T) { assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[workspace.ID]) } +// uniqueViolationStore wraps a database.Store and injects a unique violation +// error from InsertWorkspaceBuild after a configurable number of successful +// calls. This simulates a concurrent build race (e.g. an API-driven start +// racing with the lifecycle executor autostart). +type uniqueViolationStore struct { + database.Store + insertCount *atomic.Int32 // pointer: shared across InTx copies + failAfterN int32 +} + +func newUniqueViolationStore(db database.Store, failAfterN int32) *uniqueViolationStore { + return &uniqueViolationStore{ + Store: db, + insertCount: &atomic.Int32{}, + failAfterN: failAfterN, + } +} + +func (s *uniqueViolationStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error { + return s.Store.InTx(func(tx database.Store) error { + return fn(&uniqueViolationStore{ + Store: tx, + insertCount: s.insertCount, // shared pointer + failAfterN: s.failAfterN, + }) + }, opts) +} + +func (s *uniqueViolationStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { + n := s.insertCount.Add(1) + if n > s.failAfterN { + return &pq.Error{ + Code: pq.ErrorCode("23505"), + Constraint: string(database.UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey), + Message: `duplicate key value violates unique constraint "workspace_builds_workspace_id_build_number_key"`, + } + } + return s.Store.InsertWorkspaceBuild(ctx, arg) +} + +func TestExecutorBuildNumberRaceIsHandled(t *testing.T) { + t.Parallel() + + // The lifecycle executor must handle a unique-violation from + // InsertWorkspaceBuild gracefully. This error occurs when a concurrent + // actor (API handler, another executor, prebuilds reconciler) inserts a + // build with the same number before the executor's INSERT lands. + // + // We inject the error via a store wrapper. The first two + // InsertWorkspaceBuild calls succeed (setup builds), then the third + // (the lifecycle executor's autostart build) gets a unique violation. + + realDB, ps := dbtestutil.NewDB(t) + wrappedDB := newUniqueViolationStore(realDB, 2) // Allow builds 1 (start) and 2 (stop); fail build 3 (autostart) + + var ( + sched, _ = cron.Weekly("CRON_TZ=UTC 0 * * * *") + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AutobuildTicker: tickCh, + AutobuildStats: statsCh, + Database: wrappedDB, + Pubsub: ps, + }) + workspace = mustProvisionWorkspace(t, client, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + ) + + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + + p, err := coderdtest.GetProvisionerForTags(realDB, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + next := sched.Next(workspace.LatestBuild.CreatedAt) + coderdtest.UpdateProvisionerLastSeenAt(t, realDB, p.ID, next) + + tickCh <- next + stats := <-statsCh + + // The lifecycle executor should treat the unique violation as a benign + // race, not as a hard error. + assert.Empty(t, stats.Errors, "lifecycle executor should not report unique-violation as error") +} + func TestExecutorAutostartTemplateUpdated(t *testing.T) { t.Parallel()