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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 72 additions & 11 deletions coderd/ai_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
Expand Down Expand Up @@ -233,6 +234,8 @@ func (api *API) aiProvidersCreate(rw http.ResponseWriter, r *http.Request) {
}
aReq.New = row

auditAIProviderKeyChanges(ctx, r, *auditor, api.Logger, aiProviderKeyChanges{Added: keys})

sdk, err := db2sdk.AIProvider(row, keys)
if err != nil {
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", row.ID), slog.Error(err))
Expand Down Expand Up @@ -295,8 +298,9 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
idOrName := chi.URLParam(r, "idOrName")

var (
updated database.AIProvider
keys []database.AIProviderKey
updated database.AIProvider
keys []database.AIProviderKey
keyChanges aiProviderKeyChanges
)
err := api.Database.InTx(func(tx database.Store) error {
old, err := lookupAIProvider(ctx, tx, idOrName)
Expand Down Expand Up @@ -354,7 +358,7 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {

if req.APIKeys != nil {
var ops aiProviderKeyOpsAudit
keys, ops, err = applyAIProviderKeyOps(ctx, tx, updated.ID, *req.APIKeys)
keys, ops, keyChanges, err = applyAIProviderKeyOps(ctx, tx, updated.ID, *req.APIKeys)
if err != nil {
return err
}
Expand Down Expand Up @@ -395,6 +399,8 @@ func (api *API) aiProvidersUpdate(rw http.ResponseWriter, r *http.Request) {
return
}

auditAIProviderKeyChanges(ctx, r, *auditor, api.Logger, keyChanges)

sdk, err := db2sdk.AIProvider(updated, keys)
if err != nil {
api.Logger.Error(ctx, "convert AI provider", slog.F("provider_id", updated.ID), slog.Error(err))
Expand Down Expand Up @@ -575,17 +581,70 @@ type aiProviderKeyOp struct {
Masked string `json:"masked"`
}

// aiProviderKeyChanges captures the rows added and removed by
// applyAIProviderKeyOps so the caller can emit one audit entry per
// affected key after the transaction commits.
type aiProviderKeyChanges struct {
Added []database.AIProviderKey
Removed []database.AIProviderKey
}

// auditAIProviderKeyChanges emits one audit entry per added or removed
// key, attributed to the actor on the HTTP request. Per-key entries
// keep key rotation visible in the audit log because the parent
// AIProvider audit diff is empty for key-only PATCHes (keys live in a
// separate table).
//
// APIKey is replaced with the masked rendering before the row reaches
// the audit pipeline so plaintext keys never land in the diff or any
// audit backend, independent of the api_key column's audit policy.
func auditAIProviderKeyChanges(ctx context.Context, r *http.Request, auditor audit.Auditor, log slog.Logger, changes aiProviderKeyChanges) {
if len(changes.Added) == 0 && len(changes.Removed) == 0 {
return
}
key, ok := httpmw.APIKeyOptional(r)
if !ok {
return
}
requestID, _ := httpmw.RequestIDOptional(r)
emit := func(action database.AuditAction, before, after database.AIProviderKey) {
before.APIKey = aibridgeutils.MaskSecret(before.APIKey)
after.APIKey = aibridgeutils.MaskSecret(after.APIKey)
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.AIProviderKey]{
Audit: auditor,
Log: log,
UserID: key.UserID,
RequestID: requestID,
Status: http.StatusOK,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
Action: action,
Old: before,
New: after,
})
}
for _, k := range changes.Removed {
emit(database.AuditActionDelete, k, database.AIProviderKey{})
}
for _, k := range changes.Added {
emit(database.AuditActionCreate, database.AIProviderKey{}, k)
}
}

// applyAIProviderKeyOps reconciles a provider's keys against the
// supplied mutation list inside a transaction: kept-by-ID rows stay,
// rows whose ID is absent from the list are deleted, and entries
// carrying a plaintext APIKey are inserted as new rows. Caller is
// responsible for prior validation (XOR per entry, no duplicate IDs).
// IDs that do not belong to this provider return errAIProviderKeyUnknown.
func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uuid.UUID, muts []codersdk.AIProviderKeyMutation) ([]database.AIProviderKey, aiProviderKeyOpsAudit, error) {
var ops aiProviderKeyOpsAudit
func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uuid.UUID, muts []codersdk.AIProviderKeyMutation) ([]database.AIProviderKey, aiProviderKeyOpsAudit, aiProviderKeyChanges, error) {
var (
ops aiProviderKeyOpsAudit
changes aiProviderKeyChanges
)
existing, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
if err != nil {
return nil, ops, xerrors.Errorf("load existing ai provider keys: %w", err)
return nil, ops, changes, xerrors.Errorf("load existing ai provider keys: %w", err)
}
existingByID := make(map[uuid.UUID]struct{}, len(existing))
for _, k := range existing {
Expand All @@ -598,7 +657,7 @@ func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uu
switch {
case m.ID != nil:
if _, ok := existingByID[*m.ID]; !ok {
return nil, ops, xerrors.Errorf("%w: %s", errAIProviderKeyUnknown, *m.ID)
return nil, ops, changes, xerrors.Errorf("%w: %s", errAIProviderKeyUnknown, *m.ID)
}
keep[*m.ID] = struct{}{}
case m.APIKey != nil:
Expand All @@ -611,25 +670,27 @@ func applyAIProviderKeyOps(ctx context.Context, tx database.Store, providerID uu
continue
}
if err := tx.DeleteAIProviderKey(ctx, k.ID); err != nil {
return nil, ops, xerrors.Errorf("delete ai provider key %s: %w", k.ID, err)
return nil, ops, changes, xerrors.Errorf("delete ai provider key %s: %w", k.ID, err)
}
ops.Removed = append(ops.Removed, aiProviderKeyOp{ID: k.ID, Masked: aibridgeutils.MaskSecret(k.APIKey)})
changes.Removed = append(changes.Removed, k)
}

added, err := insertAIProviderKeys(ctx, tx, providerID, inserts)
if err != nil {
return nil, ops, err
return nil, ops, changes, err
}
for _, k := range added {
ops.Added = append(ops.Added, aiProviderKeyOp{ID: k.ID, Masked: aibridgeutils.MaskSecret(k.APIKey)})
}
changes.Added = append(changes.Added, added...)
ops.Kept = len(keep)

out, err := tx.GetAIProviderKeysByProviderID(ctx, providerID)
if err != nil {
return nil, ops, xerrors.Errorf("reload ai provider keys: %w", err)
return nil, ops, changes, xerrors.Errorf("reload ai provider keys: %w", err)
}
return out, ops, nil
return out, ops, changes, nil
}

// errAIProviderKeyUnknown is the sentinel returned by
Expand Down
65 changes: 65 additions & 0 deletions coderd/ai_providers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -994,6 +994,52 @@ func TestAIProvidersKeyManagement(t *testing.T) {
require.Contains(t, sdkErr.Validations[0].Detail, "already referenced")
})

t.Run("PATCHPropertiesAudited", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)

//nolint:gocritic // Owner role is the audience for this endpoint.
provider, err := client.CreateAIProvider(ctx, codersdk.CreateAIProviderRequest{
Type: codersdk.AIProviderTypeOpenAI,
Name: "keys-props-audit",
Enabled: true,
BaseURL: "https://api.openai.com/v1",
})
require.NoError(t, err)

// Reset before the update so we look only at audits produced by
// the PATCH (the create path emits its own AIProvider audit).
auditor.ResetLogs()

newDisplay := "Renamed"
newURL := "https://api.openai.com/v2"
disabled := false
_, err = client.UpdateAIProvider(ctx, provider.Name, codersdk.UpdateAIProviderRequest{
DisplayName: &newDisplay,
BaseURL: &newURL,
Enabled: &disabled,
})
require.NoError(t, err)

// The parent AIProvider audit entry fires for property-only
// PATCHes; the enterprise auditor populates the diff with the
// changed fields (display_name, base_url, enabled). The mock
// auditor used here returns an empty diff so we only assert the
// entry shape; the actual diff content is exercised by the
// enterprise audit unit tests.
var sawUpdate bool
for _, lg := range auditor.AuditLogs() {
if lg.Action == database.AuditActionWrite && lg.ResourceType == database.ResourceTypeAIProvider {
require.Equal(t, provider.ID, lg.ResourceID)
sawUpdate = true
}
}
require.True(t, sawUpdate, "expected parent AIProvider audit for property-only PATCH")
})

t.Run("PATCHKeysSurfacesOpsInAudit", func(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
Expand Down Expand Up @@ -1059,6 +1105,25 @@ func TestAIProvidersKeyManagement(t *testing.T) {
})
require.NoError(t, err)
require.JSONEq(t, string(expected), string(updated.AdditionalFields))

// Per-key audit entries surface the added/removed keys as their
// own log lines, so a key-only PATCH is visible even without
// frontend changes. The Create handler also emits per-key
// audits for the initial two keys, so match by ResourceID.
var sawCreate, sawDelete bool
for _, lg := range logs {
if lg.ResourceType != database.ResourceTypeAIProviderKey {
continue
}
switch {
case lg.Action == database.AuditActionCreate && lg.ResourceID == added.ID:
sawCreate = true
case lg.Action == database.AuditActionDelete && lg.ResourceID == removed.ID:
sawDelete = true
}
}
require.True(t, sawCreate, "expected create audit for added key")
require.True(t, sawDelete, "expected delete audit for removed key")
})

t.Run("MutationUnknownIDRejected", func(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions coderd/audit/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Auditable interface {
database.TaskTable |
database.AiSeatState |
database.AIProvider |
database.AIProviderKey |
database.Chat |
database.AuditableGroupAiBudget |
database.UserSecret |
Expand Down
10 changes: 10 additions & 0 deletions coderd/audit/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ func ResourceTarget[T Auditable](tgt T) string {
return "AI Seat"
case database.AIProvider:
return typed.Name
case database.AIProviderKey:
return typed.ID.String()
case database.AuditableGroupAiBudget:
return typed.GroupName
case database.Chat:
Expand Down Expand Up @@ -218,6 +220,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
return typed.UserID
case database.AIProvider:
return typed.ID
case database.AIProviderKey:
return typed.ID
case database.AuditableGroupAiBudget:
return typed.GroupID
case database.Chat:
Expand Down Expand Up @@ -285,6 +289,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
return database.ResourceTypeAiSeat
case database.AIProvider:
return database.ResourceTypeAIProvider
case database.AIProviderKey:
return database.ResourceTypeAIProviderKey
case database.AuditableGroupAiBudget:
return database.ResourceTypeGroupAiBudget
case database.Chat:
Expand Down Expand Up @@ -356,6 +362,10 @@ func ResourceRequiresOrgID[T Auditable]() bool {
case database.AIProvider:
// AI providers are deployment-scoped, not org-scoped.
return false
case database.AIProviderKey:
// AI provider keys inherit the deployment scope of their parent
// provider.
return false
case database.AuditableGroupAiBudget:
// Group AI budgets are org-scoped through their parent group.
return true
Expand Down
Loading
Loading