feat(coderd): list workspace secrets on workspace build audit entries

This commit is contained in:
Steven Masley
2026-10-15 21:25:16 +00:00
parent 491f240f15
commit 93d7cd3c29
5 changed files with 124 additions and 20 deletions
+3
View File
@@ -22,6 +22,9 @@ type AdditionalFields struct {
BuildReason database.BuildReason `json:"build_reason"`
WorkspaceOwner string `json:"workspace_owner"`
WorkspaceID uuid.UUID `json:"workspace_id"`
// WorkspaceSecrets lists the secrets linked to a workspace build. Set
// only on workspace build entries.
WorkspaceSecrets []WorkspaceBuildSecret `json:"workspace_secrets,omitempty"`
}
func NewNop() Auditor {
+52
View File
@@ -0,0 +1,52 @@
package audit
import (
"context"
"strconv"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
)
// WorkspaceBuildSecret is the metadata of a secret linked to a workspace
// build. Secret values are never audited.
type WorkspaceBuildSecret struct {
Name string `json:"name"`
EnvName string `json:"env_name"`
FilePath string `json:"file_path"`
Source database.WorkspaceSecretSource `json:"source"`
}
// WorkspaceBuildFields returns the additional fields for a workspace build
// audit entry, including the full set of secrets linked to the build.
// Removed secrets have no row, so they are absent from the set. If the
// secrets cannot be listed, the other fields are still returned with the
// error.
func WorkspaceBuildFields(ctx context.Context, db database.Store, workspace database.Workspace, build database.WorkspaceBuild) (AdditionalFields, error) {
fields := AdditionalFields{
WorkspaceName: workspace.Name,
BuildNumber: strconv.FormatInt(int64(build.BuildNumber), 10),
BuildReason: build.Reason,
WorkspaceID: workspace.ID,
}
//nolint:gocritic // Audit entries list secret metadata for every build initiator; values are never read.
rows, err := db.GetWorkspaceSecretsHistory(dbauthz.AsWorkspaceSecretManager(ctx), database.GetWorkspaceSecretsHistoryParams{
WorkspaceID: workspace.ID,
WorkspaceBuildID: build.ID,
})
if err != nil {
return fields, xerrors.Errorf("list workspace build secrets: %w", err)
}
for _, row := range rows {
fields.WorkspaceSecrets = append(fields.WorkspaceSecrets, WorkspaceBuildSecret{
Name: row.Name,
EnvName: row.EnvName,
FilePath: row.FilePath,
Source: row.Source,
})
}
return fields, nil
}
@@ -1500,13 +1500,9 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto.
previousBuild = database.WorkspaceBuild{}
}
// We pass the below information to the Auditor so that it
// can form a friendly string for the user to view in the UI.
buildResourceInfo := audit.AdditionalFields{
WorkspaceName: workspace.Name,
BuildNumber: strconv.FormatInt(int64(build.BuildNumber), 10),
BuildReason: database.BuildReason(string(build.Reason)),
WorkspaceID: workspace.ID,
buildResourceInfo, err := audit.WorkspaceBuildFields(ctx, s.Database, workspace, build)
if err != nil {
s.Logger.Error(ctx, "audit log - workspace build fields", slog.Error(err))
}
wriBytes, err := json.Marshal(buildResourceInfo)
@@ -2497,13 +2493,9 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
previousBuild = database.WorkspaceBuild{}
}
// We pass the below information to the Auditor so that it
// can form a friendly string for the user to view in the UI.
buildResourceInfo := audit.AdditionalFields{
WorkspaceName: workspace.Name,
BuildNumber: strconv.FormatInt(int64(workspaceBuild.BuildNumber), 10),
BuildReason: database.BuildReason(string(workspaceBuild.Reason)),
WorkspaceID: workspace.ID,
buildResourceInfo, err := audit.WorkspaceBuildFields(ctx, s.Database, workspace, workspaceBuild)
if err != nil {
s.Logger.Error(ctx, "audit log - workspace build fields", slog.Error(err))
}
wriBytes, err := json.Marshal(buildResourceInfo)
+4 -6
View File
@@ -595,13 +595,11 @@ func (api *API) postWorkspaceBuildsInternal(
slog.F("workspace_build_id", workspaceBuild.ID),
slog.F("provisioner_job_id", provisionerJob.ID),
)
buildResourceInfo := audit.AdditionalFields{
WorkspaceName: workspace.Name,
BuildNumber: strconv.Itoa(int(workspaceBuild.BuildNumber)),
BuildReason: workspaceBuild.Reason,
WorkspaceID: workspace.ID,
WorkspaceOwner: workspace.OwnerName,
buildResourceInfo, err := audit.WorkspaceBuildFields(ctx, api.Database, workspace, *workspaceBuild)
if err != nil {
api.Logger.Error(ctx, "failed to list workspace build secrets for audit", slog.Error(err))
}
buildResourceInfo.WorkspaceOwner = workspace.OwnerName
briBytes, err := json.Marshal(buildResourceInfo)
if err != nil {
api.Logger.Error(ctx, "failed to marshal build resource info for audit", slog.Error(err))
+59
View File
@@ -7124,3 +7124,62 @@ func TestWorkspaceBuildSecretsFilePathBlocked(t *testing.T) {
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
}
func TestWorkspaceSecretsAudit(t *testing.T) {
t.Parallel()
auditor := audit.NewMock()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ctx := testutil.Context(t, testutil.WaitLong)
// buildSecrets returns the secrets listed on the audit entry for a build.
// The entry is written after the job completes, so wait for it.
buildSecrets := func(t *testing.T, buildID uuid.UUID) []audit.WorkspaceBuildSecret {
t.Helper()
var entry database.AuditLog
testutil.Eventually(ctx, t, func(context.Context) bool {
for _, log := range auditor.AuditLogs() {
if log.ResourceType == database.ResourceTypeWorkspaceBuild && log.ResourceID == buildID {
entry = log
return true
}
}
return false
}, testutil.IntervalFast, "audit entry for build %s", buildID)
require.NotContains(t, string(entry.AdditionalFields), "secret-value", "values must never be audited")
var fields audit.AdditionalFields
require.NoError(t, json.Unmarshal(entry.AdditionalFields, &fields))
return fields.WorkspaceSecrets
}
workspace := coderdtest.CreateWorkspace(t, client, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
req.Secrets = []codersdk.WorkspaceSecretInput{
{Name: "api-key", Value: new("secret-value"), EnvName: "API_KEY"},
{Name: "cert", Value: new("secret-value"), FilePath: "/home/coder/.cert"},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
require.ElementsMatch(t, []audit.WorkspaceBuildSecret{
{Name: "api-key", EnvName: "API_KEY", Source: database.WorkspaceSecretSourceRequest},
{Name: "cert", FilePath: "/home/coder/.cert", Source: database.WorkspaceSecretSourceRequest},
}, buildSecrets(t, workspace.LatestBuild.ID))
// The entry lists the build's full set. Removed secrets are absent.
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStop,
Secrets: []codersdk.WorkspaceSecretInput{
{Name: "cert"},
{Name: "token", Value: new("secret-value"), EnvName: "TOKEN"},
},
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
require.ElementsMatch(t, []audit.WorkspaceBuildSecret{
{Name: "api-key", EnvName: "API_KEY", Source: database.WorkspaceSecretSourceCarryForward},
{Name: "token", EnvName: "TOKEN", Source: database.WorkspaceSecretSourceRequest},
}, buildSecrets(t, build.ID))
}