diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index be71ec44e1..e6dd2eefe8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17876,7 +17876,7 @@ const docTemplate = `{ "type": "object", "properties": { "id": { - "description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.", + "description": "ID identifies the request for response ordering. Websocket response\nIDs are monotonically increasing and may exceed the request ID when\nserver-side events trigger additional renders.", "type": "integer" }, "inputs": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9f900a3435..2c9eac6b40 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16236,7 +16236,7 @@ "type": "object", "properties": { "id": { - "description": "ID identifies the request. The response contains the same\nID so that the client can match it to the request.", + "description": "ID identifies the request for response ordering. Websocket response\nIDs are monotonically increasing and may exceed the request ID when\nserver-side events trigger additional renders.", "type": "integer" }, "inputs": { diff --git a/coderd/parameters.go b/coderd/parameters.go index f39d05ab2a..2531655651 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -8,16 +8,23 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/dynamicparameters" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/usersecretspubsub" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/websocket" ) +const initialDynamicParametersResponseID = -1 + // @Summary Evaluate dynamic parameters for template version // @ID evaluate-dynamic-parameters-for-template-version // @Security CoderSessionToken @@ -63,7 +70,7 @@ func (api *API) templateVersionDynamicParametersWebsocket(rw http.ResponseWriter } api.templateVersionDynamicParameters(true, codersdk.DynamicParametersRequest{ - ID: -1, + ID: initialDynamicParametersResponseID, Inputs: map[string]string{}, OwnerID: userID, })(rw, r) @@ -117,16 +124,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini ctx := r.Context() // Send an initial form state, computed without any user input. - result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements()) - response := codersdk.DynamicParametersResponse{ - ID: 0, - Diagnostics: db2sdk.HCLDiagnostics(diagnostics), - } - if result.Output != nil { - response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) - } - response.SecretRequirements = result.SecretRequirements - + response := renderDynamicParametersResponse(ctx, render, 0, initial.OwnerID, initial.Inputs) httpapi.Write(ctx, rw, http.StatusOK, response) } @@ -151,31 +149,43 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request api.Logger, ) + secretEvents := make(chan uuid.UUID, 1) + secretSubscriber := ¶meterSecretEventSubscriber{ + api: api, + events: secretEvents, + } + secretSubscriber.UpdateOwnerSubscription(ctx, initial.OwnerID) + defer secretSubscriber.Close() + + sender := dynamicParametersResponseSender{ + stream: stream, + render: render, + } + // Send an initial form state, computed without any user input. - result, diagnostics := render.Render(ctx, initial.OwnerID, initial.Inputs, dynamicparameters.IncludeSecretRequirements()) - response := codersdk.DynamicParametersResponse{ - ID: -1, // Always start with -1. - Diagnostics: db2sdk.HCLDiagnostics(diagnostics), - } - if result.Output != nil { - response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) - } - response.SecretRequirements = result.SecretRequirements - err = stream.Send(response) - if err != nil { - stream.Drop() + if !sender.Send(ctx, initialDynamicParametersResponseID, initial.OwnerID, initial.Inputs) { return } - // As the user types into the form, reprocess the state using their input, - // and respond with updates. + // As the user types into the form or updates secrets in another client, + // reprocess the state using their input and respond with updates. updates := stream.Chan() ownerID := initial.OwnerID + inputs := initial.Inputs + lastResponseID := initialDynamicParametersResponseID for { select { case <-ctx.Done(): stream.Close(websocket.StatusGoingAway) return + case eventOwnerID := <-secretEvents: + if eventOwnerID != ownerID { + continue + } + lastResponseID = nextDynamicParametersResponseID(lastResponseID, lastResponseID+1) + if !sender.Send(ctx, lastResponseID, ownerID, inputs) { + return + } case update, ok := <-updates: if !ok { // The connection has been closed, so there is no one to write to @@ -189,21 +199,130 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request } ownerID = update.OwnerID - - result, diagnostics := render.Render(ctx, update.OwnerID, update.Inputs, dynamicparameters.IncludeSecretRequirements()) - response := codersdk.DynamicParametersResponse{ - ID: update.ID, - Diagnostics: db2sdk.HCLDiagnostics(diagnostics), - } - if result.Output != nil { - response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) - } - response.SecretRequirements = result.SecretRequirements - err = stream.Send(response) - if err != nil { - stream.Drop() + inputs = update.Inputs + secretSubscriber.UpdateOwnerSubscription(ctx, ownerID) + responseID := nextDynamicParametersResponseID(lastResponseID, update.ID) + lastResponseID = responseID + if !sender.Send(ctx, responseID, ownerID, inputs) { return } } } } + +func renderDynamicParametersResponse( + ctx context.Context, + render dynamicparameters.Renderer, + id int, + ownerID uuid.UUID, + inputs map[string]string, +) codersdk.DynamicParametersResponse { + result, diagnostics := render.Render(ctx, ownerID, inputs, dynamicparameters.IncludeSecretRequirements()) + response := codersdk.DynamicParametersResponse{ + ID: id, + Diagnostics: db2sdk.HCLDiagnostics(diagnostics), + } + if result.Output != nil { + response.Parameters = slice.List(result.Output.Parameters, db2sdk.PreviewParameter) + } + response.SecretRequirements = result.SecretRequirements + return response +} + +type dynamicParametersResponseSender struct { + stream *wsjson.Stream[codersdk.DynamicParametersRequest, codersdk.DynamicParametersResponse] + render dynamicparameters.Renderer +} + +func (s dynamicParametersResponseSender) Send( + ctx context.Context, + id int, + ownerID uuid.UUID, + inputs map[string]string, +) bool { + response := renderDynamicParametersResponse(ctx, s.render, id, ownerID, inputs) + if err := s.stream.Send(response); err != nil { + s.stream.Drop() + return false + } + return true +} + +type parameterSecretEventSubscriber struct { + api *API + events chan uuid.UUID + + cancel func() + ownerID uuid.UUID +} + +// UpdateOwnerSubscription switches the pubsub subscription to the owner's +// user secret channel. Dynamic parameters can render for a workspace owner +// other than the connected user, so owner changes must update the channel +// that drives secret requirement refreshes. +func (s *parameterSecretEventSubscriber) UpdateOwnerSubscription(ctx context.Context, ownerID uuid.UUID) { + if ownerID == s.ownerID { + return + } + if s.cancel != nil { + s.Close() + } + // Websocket authorization uses the actor snapshot from connection + // creation, matching the rest of the websocket handlers. + if !s.api.canSubscribeUserSecretEvents(ctx, ownerID) { + s.ownerID = ownerID + return + } + s.ownerID = ownerID + subscribedOwnerID := ownerID + cancel, err := s.api.Pubsub.Subscribe(usersecretspubsub.Channel(ownerID), func(context.Context, []byte) { + s.notify(subscribedOwnerID) + }) + if err != nil { + // Leave the owner unset so transient pubsub failures can be + // retried on the next update for this owner. + s.ownerID = uuid.Nil + s.api.Logger.Warn(ctx, "failed to subscribe to user secret events", + slog.F("user_id", ownerID), + slog.Error(err), + ) + return + } + s.cancel = cancel +} + +func (s *parameterSecretEventSubscriber) Close() { + if s.cancel == nil { + return + } + s.cancel() + s.cancel = nil +} + +func (s *parameterSecretEventSubscriber) notify(ownerID uuid.UUID) { + select { + case s.events <- ownerID: + default: + } +} + +func nextDynamicParametersResponseID(lastResponseID int, requestID int) int { + if requestID <= lastResponseID { + return lastResponseID + 1 + } + return requestID +} + +func (api *API) canSubscribeUserSecretEvents(ctx context.Context, ownerID uuid.UUID) bool { + roles, ok := dbauthz.ActorFromContext(ctx) + if !ok { + api.Logger.Error(ctx, "no authorization actor for user secret event subscription") + return false + } + return api.HTTPAuth.Authorizer.Authorize( + ctx, + roles, + policy.ActionRead, + rbac.ResourceUserSecret.WithOwner(ownerID.String()), + ) == nil +} diff --git a/coderd/parameters_internal_test.go b/coderd/parameters_internal_test.go new file mode 100644 index 0000000000..4b08064ffd --- /dev/null +++ b/coderd/parameters_internal_test.go @@ -0,0 +1,148 @@ +package coderd + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/testutil" +) + +func TestNextDynamicParametersResponseID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lastResponseID int + requestID int + want int + }{ + { + name: "request ID advances response ID", + lastResponseID: 1, + requestID: 4, + want: 4, + }, + { + name: "request ID collision advances response ID", + lastResponseID: 4, + requestID: 4, + want: 5, + }, + { + name: "stale request ID advances response ID", + lastResponseID: 4, + requestID: 2, + want: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := nextDynamicParametersResponseID(tt.lastResponseID, tt.requestID) + require.Equal(t, tt.want, got) + }) + } +} + +func TestCanSubscribeUserSecretEventsRequiresSecretRead(t *testing.T) { + t.Parallel() + + ownerID := uuid.New() + actor := rbac.Subject{ID: uuid.NewString()} + + t.Run("allowed", func(t *testing.T) { + t.Parallel() + + auth := &recordingAuthorizer{} + api := &API{ + Options: &Options{ + Logger: testutil.Logger(t), + }, + HTTPAuth: &HTTPAuthorizer{ + Authorizer: auth, + Logger: testutil.Logger(t), + }, + } + ctx := dbauthz.As(t.Context(), actor) //nolint:gocritic // Testing authorization from the request context. + + require.True(t, api.canSubscribeUserSecretEvents(ctx, ownerID)) + require.Len(t, auth.calls, 1) + require.Equal(t, actor, auth.calls[0].Actor) + require.Equal(t, policy.ActionRead, auth.calls[0].Action) + require.Equal(t, rbac.ResourceUserSecret.Type, auth.calls[0].Object.Type) + require.Equal(t, ownerID.String(), auth.calls[0].Object.Owner) + }) + + t.Run("denied", func(t *testing.T) { + t.Parallel() + + auth := &recordingAuthorizer{err: xerrors.New("denied")} + api := &API{ + Options: &Options{ + Logger: testutil.Logger(t), + }, + HTTPAuth: &HTTPAuthorizer{ + Authorizer: auth, + Logger: testutil.Logger(t), + }, + } + ctx := dbauthz.As(t.Context(), actor) //nolint:gocritic // Testing authorization from the request context. + + require.False(t, api.canSubscribeUserSecretEvents(ctx, ownerID)) + require.Len(t, auth.calls, 1) + }) + + t.Run("no actor", func(t *testing.T) { + t.Parallel() + + auth := &recordingAuthorizer{} + logger := slogtest.Make(t, &slogtest.Options{ + IgnoredErrorIs: []error{}, + IgnoreErrorFn: func(entry slog.SinkEntry) bool { + return entry.Message == "no authorization actor for user secret event subscription" + }, + }) + api := &API{ + Options: &Options{ + Logger: logger, + }, + HTTPAuth: &HTTPAuthorizer{ + Authorizer: auth, + Logger: logger, + }, + } + + require.False(t, api.canSubscribeUserSecretEvents(context.Background(), ownerID)) + require.Empty(t, auth.calls) + }) +} + +type recordingAuthorizer struct { + err error + calls []rbac.AuthCall +} + +func (a *recordingAuthorizer) Authorize(_ context.Context, subject rbac.Subject, action policy.Action, object rbac.Object) error { + a.calls = append(a.calls, rbac.AuthCall{ + Actor: subject, + Action: action, + Object: object, + }) + return a.err +} + +func (*recordingAuthorizer) Prepare(context.Context, rbac.Subject, policy.Action, string) (rbac.PreparedAuthorized, error) { + //nolint:nilnil // Prepare is unused by these tests. + return nil, nil +} diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 3473cc01e8..442682b5e0 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -414,6 +414,183 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { }}, preview.SecretRequirements) }) + t.Run("SecretRequirementPushesOnSecretChange", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + previews := setup.stream.Chan() + + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_test", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 0, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.True(t, preview.SecretRequirements[0].Satisfied) + + err = setup.dynamicParamsClient.DeleteUserSecret(ctx, codersdk.Me, "github-token") + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_test", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 2, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.True(t, preview.SecretRequirements[0].Satisfied) + + otherEnvName := "OTHER_GITHUB_TOKEN" + _, err = setup.dynamicParamsClient.UpdateUserSecret(ctx, codersdk.Me, "github-token", codersdk.UpdateUserSecretRequest{ + EnvName: &otherEnvName, + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 3, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + }) + + t.Run("SecretRequirementPushesAfterOwnerSwitch", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + // No production role grants cross-user user_secret:read today, + // so use an allow-all authorizer for lifecycle coverage. + authorizer: &coderdtest.FakeAuthorizer{}, + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + previews := setup.stream.Chan() + targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID) + + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + err = setup.stream.Send(codersdk.DynamicParametersRequest{ + ID: 0, + Inputs: map[string]string{}, + OwnerID: target.ID, + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 0, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + _, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_target", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.True(t, preview.SecretRequirements[0].Satisfied) + + _, err = setup.dynamicParamsClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_initial", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + require.Never(t, func() bool { + select { + case <-previews: + return true + default: + return false + } + }, testutil.WaitShort/5, testutil.IntervalFast) + }) + + t.Run("SecretRequirementDoesNotSubscribeWhenOwnerUnauthorized", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/secret_required/main.tf") + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + previews := setup.stream.Chan() + targetClient, target := coderdtest.CreateAnotherUser(t, setup.client, setup.template.OrganizationID) + + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Len(t, preview.SecretRequirements, 1) + require.False(t, preview.SecretRequirements[0].Satisfied) + + err = setup.stream.Send(codersdk.DynamicParametersRequest{ + ID: 0, + Inputs: map[string]string{}, + OwnerID: target.ID, + }) + require.NoError(t, err) + + preview = testutil.RequireReceive(ctx, t, previews) + require.Equal(t, 0, preview.ID) + require.Empty(t, preview.SecretRequirements) + require.Len(t, preview.Diagnostics, 1) + require.Equal(t, dynamicparameters.DiagCodeSecretValidationForbidden, preview.Diagnostics[0].Extra.Code) + + _, err = targetClient.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{ + Name: "github-token", + Value: "ghp_target", + EnvName: "GITHUB_TOKEN", + }) + require.NoError(t, err) + + require.Never(t, func() bool { + select { + case <-previews: + return true + default: + return false + } + }, testutil.WaitShort/5, testutil.IntervalFast) + }) + // Regression test for PLAT-100: a workspace whose template has an // unsatisfied coder_secret requirement must still be stoppable and // deletable. Start remains blocked. @@ -475,6 +652,7 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { type setupDynamicParamsTestParams struct { db database.Store ps pubsub.Pubsub + authorizer rbac.Authorizer provisionerDaemonVersion string mainTF []byte modulesArchive []byte @@ -486,16 +664,18 @@ type setupDynamicParamsTestParams struct { } type dynamicParamsTest struct { - client *codersdk.Client - api *coderd.API - stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] - template codersdk.Template + client *codersdk.Client + dynamicParamsClient *codersdk.Client + api *coderd.API + stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] + template codersdk.Template } func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest { ownerClient, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ Database: args.db, Pubsub: args.ps, + Authorizer: args.authorizer, IncludeProvisionerDaemon: true, ProvisionerDaemonVersion: args.provisionerDaemonVersion, }) @@ -530,10 +710,11 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn }) return dynamicParamsTest{ - client: ownerClient, - api: api, - stream: stream, - template: tpl, + client: ownerClient, + dynamicParamsClient: templateAdmin, + api: api, + stream: stream, + template: tpl, } } diff --git a/coderd/usersecrets.go b/coderd/usersecrets.go index 78ca22f776..57aa6d9f16 100644 --- a/coderd/usersecrets.go +++ b/coderd/usersecrets.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "errors" "net/http" @@ -9,11 +10,13 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/usersecretspubsub" "github.com/coder/coder/v2/codersdk" ) @@ -106,6 +109,14 @@ func (api *API) postUserSecret(rw http.ResponseWriter, r *http.Request) { } aReq.New = secret + api.publishUserSecretEvent(ctx, usersecretspubsub.Event{ + Kind: usersecretspubsub.EventKindCreated, + UserID: secret.UserID, + Name: secret.Name, + EnvName: secret.EnvName, + FilePath: secret.FilePath, + }) + httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.UserSecretFromFull(secret)) } @@ -303,6 +314,14 @@ func (api *API) patchUserSecret(rw http.ResponseWriter, r *http.Request) { return } + api.publishUserSecretEvent(ctx, usersecretspubsub.Event{ + Kind: usersecretspubsub.EventKindUpdated, + UserID: secret.UserID, + Name: secret.Name, + EnvName: secret.EnvName, + FilePath: secret.FilePath, + }) + httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSecretFromFull(secret)) } @@ -346,5 +365,22 @@ func (api *API) deleteUserSecret(rw http.ResponseWriter, r *http.Request) { } aReq.Old = deleted + api.publishUserSecretEvent(ctx, usersecretspubsub.Event{ + Kind: usersecretspubsub.EventKindDeleted, + UserID: user.ID, + Name: name, + }) + rw.WriteHeader(http.StatusNoContent) } + +func (api *API) publishUserSecretEvent(ctx context.Context, event usersecretspubsub.Event) { + if err := usersecretspubsub.Publish(api.Pubsub, event); err != nil { + api.Logger.Warn(ctx, "failed to publish user secret event", + slog.F("user_id", event.UserID), + slog.F("secret_name", event.Name), + slog.F("event_kind", event.Kind), + slog.Error(err), + ) + } +} diff --git a/coderd/usersecretspubsub/usersecretspubsub.go b/coderd/usersecretspubsub/usersecretspubsub.go new file mode 100644 index 0000000000..39fb5c0759 --- /dev/null +++ b/coderd/usersecretspubsub/usersecretspubsub.go @@ -0,0 +1,42 @@ +package usersecretspubsub + +import ( + "encoding/json" + "fmt" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database/pubsub" +) + +type EventKind string + +const ( + EventKindCreated EventKind = "created" + EventKindUpdated EventKind = "updated" + EventKindDeleted EventKind = "deleted" +) + +type Event struct { + Kind EventKind `json:"kind"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + Name string `json:"name"` + EnvName string `json:"env_name,omitempty"` + FilePath string `json:"file_path,omitempty"` +} + +func Channel(userID uuid.UUID) string { + return fmt.Sprintf("user_secrets:%s", userID) +} + +func Publish(ps pubsub.Publisher, event Event) error { + msg, err := json.Marshal(event) + if err != nil { + return xerrors.Errorf("marshal user secret event: %w", err) + } + if err := ps.Publish(Channel(event.UserID), msg); err != nil { + return xerrors.Errorf("publish user secret event: %w", err) + } + return nil +} diff --git a/codersdk/parameters.go b/codersdk/parameters.go index ba1ac864e9..7f6c9cdad1 100644 --- a/codersdk/parameters.go +++ b/codersdk/parameters.go @@ -162,8 +162,9 @@ type PreviewParameterValidation struct { } type DynamicParametersRequest struct { - // ID identifies the request. The response contains the same - // ID so that the client can match it to the request. + // ID identifies the request for response ordering. Websocket response + // IDs are monotonically increasing and may exceed the request ID when + // server-side events trigger additional renders. ID int `json:"id"` Inputs map[string]string `json:"inputs"` // OwnerID if uuid.Nil, it defaults to `codersdk.Me` diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 99ca97beff..534003f5cd 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6407,12 +6407,12 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|---------|----------|--------------|--------------------------------------------------------------------------------------------------------------| -| `id` | integer | false | | ID identifies the request. The response contains the same ID so that the client can match it to the request. | -| `inputs` | object | false | | | -| » `[any property]` | string | false | | | -| `owner_id` | string | false | | Owner ID if uuid.Nil, it defaults to `codersdk.Me` | +| Name | Type | Required | Restrictions | Description | +|--------------------|---------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | integer | false | | ID identifies the request for response ordering. Websocket response IDs are monotonically increasing and may exceed the request ID when server-side events trigger additional renders. | +| `inputs` | object | false | | | +| » `[any property]` | string | false | | | +| `owner_id` | string | false | | Owner ID if uuid.Nil, it defaults to `codersdk.Me` | ## codersdk.DynamicParametersResponse diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index bcbfa35ddb..12c18112b7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3782,8 +3782,9 @@ export const DisplayApps: DisplayApp[] = [ // From codersdk/parameters.go export interface DynamicParametersRequest { /** - * ID identifies the request. The response contains the same - * ID so that the client can match it to the request. + * ID identifies the request for response ordering. Websocket response + * IDs are monotonically increasing and may exceed the request ID when + * server-side events trigger additional renders. */ readonly id: number; readonly inputs: Record;