mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: runtime user secrets injection into workspaces (#24313)
Injects user secrets into workspace agents at runtime via the agent manifest. Secrets with an environment variable name are set as environment variables in every agent session and startup script. Secrets with a file path are written to disk before startup scripts run. - Fetch user secrets in GetManifest and convert to proto - Defensively strip secrets from manifests received by the agent to avoid accidental leakage - Add WorkspaceSecret type and proto conversion helpers to agentsdk - Write secret files eagerly on manifest fetch (0600 perms, 0700 dirs) - Inject secret env vars per-session in updateCommandEnv - Expand ~/paths using caller-resolved home directory - Log file write errors without blocking workspace startup
This commit is contained in:
@@ -101,6 +101,15 @@ type PostMetadataRequest struct {
|
||||
// performance.
|
||||
type PostMetadataRequestDeprecated = codersdk.WorkspaceAgentMetadataResult
|
||||
|
||||
// Manifest is the workspace agent's view of its own configuration.
|
||||
//
|
||||
// Secrets are intentionally not a field on this struct. The manifest
|
||||
// may be serialized (JSON, %+v, logger fields, debug endpoints) in
|
||||
// many places that do not and should not carry secret values.
|
||||
// Keeping Secrets off of the struct makes leaking them impossible
|
||||
// via any code path that only holds a *Manifest. Callers that need
|
||||
// secrets must load them explicitly via SecretsFromProto on the raw
|
||||
// proto.
|
||||
type Manifest struct {
|
||||
ParentID uuid.UUID `json:"parent_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
@@ -128,6 +137,16 @@ type Manifest struct {
|
||||
Devcontainers []codersdk.WorkspaceAgentDevcontainer `json:"devcontainers"`
|
||||
}
|
||||
|
||||
// WorkspaceSecret is a user secret for injection into a workspace.
|
||||
//
|
||||
// Value carries decrypted secret material and is omitted from JSON
|
||||
// serialization to protect against future leaking of the secret.
|
||||
type WorkspaceSecret struct {
|
||||
EnvName string
|
||||
FilePath string
|
||||
Value []byte `json:"-"`
|
||||
}
|
||||
|
||||
type LogSource struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
// ManifestFromProto converts the proto manifest to the SDK Manifest.
|
||||
// Secrets are intentionally NOT included on the returned Manifest:
|
||||
// keeping them off of the SDK type makes it impossible for any code
|
||||
// path that only holds a *Manifest to leak secret values via
|
||||
// logging, JSON encoding, fmt verbs, or debug endpoints.
|
||||
func ManifestFromProto(manifest *proto.Manifest) (Manifest, error) {
|
||||
parentID := uuid.Nil
|
||||
if pid := manifest.GetParentId(); pid != nil {
|
||||
@@ -65,6 +70,9 @@ func ManifestFromProto(manifest *proto.Manifest) (Manifest, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ProtoFromManifest converts the SDK Manifest to the proto manifest.
|
||||
// It does not populate the proto's Secrets field because the SDK
|
||||
// Manifest intentionally does not carry secrets (see ManifestFromProto).
|
||||
func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) {
|
||||
apps, err := ProtoFromApps(manifest.Apps)
|
||||
if err != nil {
|
||||
@@ -477,3 +485,27 @@ func ProtoFromPatchAppStatus(pas PatchAppStatus) (*proto.UpdateAppStatusRequest,
|
||||
Uri: pas.URI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func SecretsFromProto(protoSecrets []*proto.WorkspaceSecret) []WorkspaceSecret {
|
||||
ret := make([]WorkspaceSecret, len(protoSecrets))
|
||||
for i, s := range protoSecrets {
|
||||
ret[i] = WorkspaceSecret{
|
||||
EnvName: s.EnvName,
|
||||
FilePath: s.FilePath,
|
||||
Value: s.Value,
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func ProtoFromSecrets(secrets []WorkspaceSecret) []*proto.WorkspaceSecret {
|
||||
ret := make([]*proto.WorkspaceSecret, len(secrets))
|
||||
for i, s := range secrets {
|
||||
ret[i] = &proto.WorkspaceSecret{
|
||||
EnvName: s.EnvName,
|
||||
FilePath: s.FilePath,
|
||||
Value: s.Value,
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -233,3 +233,39 @@ func TestMetadataFromProto(t *testing.T) {
|
||||
require.Equal(t, "lemons", smd.Value)
|
||||
require.Equal(t, "rats", smd.Error)
|
||||
}
|
||||
|
||||
func TestSecretsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
secrets := []agentsdk.WorkspaceSecret{
|
||||
{
|
||||
EnvName: "GITHUB_TOKEN",
|
||||
FilePath: "",
|
||||
Value: []byte("ghp_xxxx"),
|
||||
},
|
||||
{
|
||||
EnvName: "",
|
||||
FilePath: "~/.aws/credentials",
|
||||
Value: []byte("[default]\naws_access_key_id=AKIA..."),
|
||||
},
|
||||
{
|
||||
EnvName: "BOTH_ENV",
|
||||
FilePath: "/etc/both",
|
||||
Value: []byte("both-value"),
|
||||
},
|
||||
}
|
||||
|
||||
protoSecrets := agentsdk.ProtoFromSecrets(secrets)
|
||||
require.Len(t, protoSecrets, 3)
|
||||
require.Equal(t, "GITHUB_TOKEN", protoSecrets[0].EnvName)
|
||||
require.Equal(t, "", protoSecrets[0].FilePath)
|
||||
require.Equal(t, []byte("ghp_xxxx"), protoSecrets[0].Value)
|
||||
require.Equal(t, "", protoSecrets[1].EnvName)
|
||||
require.Equal(t, "~/.aws/credentials", protoSecrets[1].FilePath)
|
||||
require.Equal(t, []byte("[default]\naws_access_key_id=AKIA..."), protoSecrets[1].Value)
|
||||
require.Equal(t, "BOTH_ENV", protoSecrets[2].EnvName)
|
||||
require.Equal(t, "/etc/both", protoSecrets[2].FilePath)
|
||||
require.Equal(t, []byte("both-value"), protoSecrets[2].Value)
|
||||
|
||||
roundTripped := agentsdk.SecretsFromProto(protoSecrets)
|
||||
require.Equal(t, secrets, roundTripped)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user