mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
Users can now disable a secret to stop it from being injected into workspaces without deleting it, and re-enable it later. Disabled secrets stay visible and editable everywhere they already appear. An enabled secret must have at least one injection target; a secret with no target can be stored only while disabled. Existing target-less secrets are migrated to disabled to preserve current behavior. Support spans the REST API, SDK, CLI, dashboard, and audit log.
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestImportUserSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("Success", 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.WaitMedium)
|
|
auditor.ResetLogs()
|
|
|
|
secrets, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "ALPHA=a\nBETA=b\nPATH=c\n",
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, secrets, 3)
|
|
// Valid keys are env-injected, while reserved names are imported
|
|
// without env injection.
|
|
assert.Equal(t, "ALPHA", secrets[0].Name)
|
|
assert.Equal(t, "ALPHA", secrets[0].EnvName)
|
|
assert.Equal(t, "PATH", secrets[2].Name)
|
|
assert.Empty(t, secrets[2].EnvName)
|
|
|
|
listed, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
names := make([]string, 0, len(listed))
|
|
for _, s := range listed {
|
|
names = append(names, s.Name)
|
|
}
|
|
assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, names)
|
|
|
|
// Exactly one create audit log per imported secret.
|
|
logs := auditor.AuditLogs()
|
|
require.Len(t, logs, 3)
|
|
resourceIDs := make([]string, 0, len(logs))
|
|
resourceTargets := make([]string, 0, len(logs))
|
|
for _, l := range logs {
|
|
assert.Equal(t, database.AuditActionCreate, l.Action)
|
|
assert.EqualValues(t, http.StatusCreated, l.StatusCode)
|
|
resourceIDs = append(resourceIDs, l.ResourceID.String())
|
|
resourceTargets = append(resourceTargets, l.ResourceTarget)
|
|
}
|
|
assert.ElementsMatch(t, []string{
|
|
secrets[0].ID.String(), secrets[1].ID.String(), secrets[2].ID.String(),
|
|
}, resourceIDs)
|
|
assert.ElementsMatch(t, []string{"ALPHA", "BETA", "PATH"}, resourceTargets)
|
|
})
|
|
|
|
t.Run("ValuesNotInResponse", func(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
const secretValue = "super-secret-sentinel-value-123"
|
|
res, err := client.Request(ctx, http.MethodPost,
|
|
fmt.Sprintf("/api/v2/users/%s/secrets/batch", codersdk.Me),
|
|
codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "LEAKY=" + secretValue,
|
|
})
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
body, err := io.ReadAll(res.Body)
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, string(body), secretValue)
|
|
})
|
|
}
|
|
|
|
func TestImportUserSecretsForbiddenForAnotherUser(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleAuditor())
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := memberClient.ImportUserSecrets(ctx, owner.UserID.String(), codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "FORBIDDEN=value",
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
}
|
|
|
|
func TestImportUserSecretsBodyTooLarge(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: strings.Repeat("a", 8*codersdk.MaxSecretsFileBytes),
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusRequestEntityTooLarge, sdkErr.StatusCode())
|
|
}
|
|
|
|
// TestImportUserSecretsValidationRollback verifies that a single
|
|
// invalid entry rejects the whole batch: nothing is created and no
|
|
// audit log is written. The valid sibling entry must not leak through.
|
|
func TestImportUserSecretsValidationRollback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
badLine string
|
|
}{
|
|
// Empty values are always invalid; this is the canonical rollback case.
|
|
{name: "EmptyValue", badLine: "EMPTY_ONE="},
|
|
{name: "OversizedValue", badLine: "BIG=" + strings.Repeat("a", codersdk.MaxUserSecretValueBytes+1)},
|
|
// A slash in the name is invalid regardless of env-name handling.
|
|
{name: "NameWithSlash", badLine: "bad/name=value"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, 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.WaitMedium)
|
|
auditor.ResetLogs()
|
|
|
|
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "GOOD_ENTRY=fine\n" + tc.badLine,
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
// Errors are attributed to the offending entry (index 1).
|
|
require.NotEmpty(t, sdkErr.Validations)
|
|
for _, v := range sdkErr.Validations {
|
|
assert.Truef(t, strings.HasPrefix(v.Field, "secrets[1]."),
|
|
"unexpected field %q", v.Field)
|
|
}
|
|
|
|
listed, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, listed)
|
|
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestImportUserSecretsConflict verifies that a batch containing an
|
|
// already-existing secret name aborts entirely: the new entry is not
|
|
// created and no audit log is written.
|
|
func TestImportUserSecretsConflict(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.WaitMedium)
|
|
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "EXISTING",
|
|
Value: "original",
|
|
EnvName: "EXISTING",
|
|
})
|
|
require.NoError(t, err)
|
|
auditor.ResetLogs()
|
|
|
|
_, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "BRANDNEW=x\nEXISTING=collision",
|
|
})
|
|
validation := requireSecretValidation(t, err, http.StatusConflict, "secrets[1].name")
|
|
assert.Equal(t, "name already in use", validation.Detail)
|
|
|
|
// Only the pre-existing secret should remain; BRANDNEW must not be created.
|
|
listed, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Len(t, listed, 1)
|
|
assert.Equal(t, "EXISTING", listed[0].Name)
|
|
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
}
|
|
|
|
// TestImportUserSecretsLimits exercises each per-user cap. A cap
|
|
// tripped mid-batch must roll back every row in the import and, because
|
|
// audit logs are emitted only after the transaction commits, write no
|
|
// import audit logs.
|
|
func TestImportUserSecretsLimits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("CountLimit", 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)
|
|
|
|
for i := 0; i < codersdk.MaxUserSecretsPerUserCount-1; i++ {
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: fmt.Sprintf("prefill-%03d", i),
|
|
Value: "original",
|
|
FilePath: fmt.Sprintf("/tmp/prefill-%03d", i),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
before, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Len(t, before, codersdk.MaxUserSecretsPerUserCount-1)
|
|
|
|
auditor.ResetLogs()
|
|
_, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "COUNT_FIRST=x\nCOUNT_SECOND=y\n",
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "secrets[1]")
|
|
|
|
after, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Len(t, after, len(before))
|
|
beforeNames := make([]string, 0, len(before))
|
|
afterNames := make([]string, 0, len(after))
|
|
for _, secret := range before {
|
|
beforeNames = append(beforeNames, secret.Name)
|
|
}
|
|
for _, secret := range after {
|
|
afterNames = append(afterNames, secret.Name)
|
|
}
|
|
assert.ElementsMatch(t, beforeNames, afterNames)
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
|
|
t.Run("EnvBytesLimit", 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)
|
|
|
|
// Every imported secret is env-injected, so two values that are
|
|
// each within the per-value cap can still exceed the env-bytes
|
|
// aggregate together.
|
|
content := fmt.Sprintf("ENV_A=%s\nENV_B=%s\n",
|
|
strings.Repeat("a", codersdk.MaxUserSecretValueBytes-16),
|
|
strings.Repeat("a", 1024))
|
|
auditor.ResetLogs()
|
|
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: content,
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "env_name")
|
|
|
|
listed, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, listed)
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
|
|
t.Run("TotalBytesLimit", 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)
|
|
|
|
// Pre-fill the total-bytes budget to the cap using file-only
|
|
// secrets (no env_name), which do not count against the smaller
|
|
// env budget. Creating them via CreateUserSecret directly avoids
|
|
// going through the import parser.
|
|
big := strings.Repeat("a", codersdk.MaxUserSecretValueBytes)
|
|
numBig := codersdk.MaxUserSecretsTotalValueBytes / codersdk.MaxUserSecretValueBytes
|
|
remainder := codersdk.MaxUserSecretsTotalValueBytes % codersdk.MaxUserSecretValueBytes
|
|
for i := 0; i < numBig; i++ {
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: fmt.Sprintf("prefill-%03d", i),
|
|
Value: big,
|
|
FilePath: fmt.Sprintf("/tmp/prefill-%03d", i),
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
if remainder > 0 {
|
|
_, err := client.CreateUserSecret(ctx, codersdk.Me, codersdk.CreateUserSecretRequest{
|
|
Name: "prefill-pad",
|
|
Value: strings.Repeat("a", remainder),
|
|
FilePath: "/tmp/prefill-pad",
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
before, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
|
|
// Reset after the prefill (which legitimately emits create audit
|
|
// logs) so the assertion below only sees logs from the rolled-back
|
|
// import.
|
|
auditor.ResetLogs()
|
|
_, err = client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatEnv,
|
|
Content: "OVERFLOW=x",
|
|
})
|
|
requireSecretAPIError(t, err, http.StatusBadRequest, "per-user budget")
|
|
|
|
after, err := client.UserSecrets(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
assert.Len(t, after, len(before))
|
|
assert.Empty(t, auditor.AuditLogs())
|
|
})
|
|
}
|
|
|
|
func TestImportUserSecretsParseErrors(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
// Parse-error variety is covered by the parser unit tests; this only
|
|
// asserts the endpoint maps a parse failure to 400.
|
|
_, err := client.ImportUserSecrets(ctx, codersdk.Me, codersdk.ImportUserSecretsRequest{
|
|
Format: codersdk.SecretsFileFormatJSON,
|
|
Content: "{not json",
|
|
})
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
}
|