From 8c6a6c1d869a14e6df897b30f7cf584ced41cd57 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:16:51 -0400 Subject: [PATCH] feat(moduleauthority): WorkloadTokenFile names the module's projected token wherever Codefly runs it Since codefly CLI v0.1.187 (codefly-dev/cli#977) a native `codefly run` stands in for the cell and projects each module workload's token to a file it names in CODEFLY__WORKLOAD_TOKEN_FILE, because a process cannot read the pod mount path. Every module consumer hardcoded /var/run/secrets/codefly/token and so minted nothing under a native run. WorkloadTokenFile() returns the file the variable names when it is set to a non-blank value, and the pod mount otherwise. The variable and the mount are exported (WorkloadTokenFileVariable, PodWorkloadTokenFile); solution-runtime-go reads the same variable. The token is still read before every mint. The CHANGELOG's stale "Unreleased" heading is renamed to v0.1.2, the tag that carries it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++++++++- README.md | 11 +++-- moduleauthority/client.go | 33 ++++++++++++++- moduleauthority/workload_token_test.go | 56 ++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 moduleauthority/workload_token_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5006be4..5fff76a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # Changelog -## Unreleased — core v0.19.1; Work Contexts are core's, carried opaque +## Unreleased + +### Added + +- **`moduleauthority.WorkloadTokenFile()`**, the one answer to "where is this + module's projected token": the file `CODEFLY__WORKLOAD_TOKEN_FILE` names + when it is set to a non-blank value, and the pod mount + `/var/run/secrets/codefly/token` otherwise. Since codefly CLI v0.1.187 + (codefly-dev/cli#977) a native `codefly run` stands in for the cell and + projects each module workload's token to a file it names in that variable, + because a process cannot read the pod mount path; a module that hardcoded + the mount minted nothing there. The variable and the path are exported as + `WorkloadTokenFileVariable` and `PodWorkloadTokenFile`; + codefly-dev/solution-runtime-go reads the same variable. It resolves the + path only: the token is still read before every mint. + +## v0.1.2 — core v0.19.1; Work Contexts are core's, carried opaque Closes #38. The SDK requires `github.com/codefly-dev/core v0.19.1` and no longer depends on `github.com/codefly-dev/sdk-go/workcontext` at all: the diff --git a/README.md b/README.md index e723adc..9d89ffc 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ The SDK surfaces are: }, moduleauthority.Credentials{ Prefix: modulePrefix, // Read before every mint: the kubelet rotates the projection. - Token: moduleauthority.ProjectedTokenFile(projectedTokenPath), + Token: moduleauthority.WorkloadTokenFile(), // Required for a shared module; a tenant-pinned module may omit it. OrganizationID: organizationID, }) @@ -292,8 +292,13 @@ The SDK surfaces are: `AllowInsecureHTTP` asserts the hop is mesh-protected. SaaS owns the installed binding's audience, scopes, lifetime, verification, and audit record. - `projectedTokenPath` is the token projection mount path supplied by the - consumer's deployment; there is no default path. The host requires audience + `WorkloadTokenFile()` is where Codefly projected the token for the process + it runs: the file `CODEFLY__WORKLOAD_TOKEN_FILE` names when that is set — + a native `codefly run` (CLI v0.1.187 and later) stands in for the cell and + projects each module's token to a file of its own, since a process cannot + read the pod mount — and the pod mount, `/var/run/secrets/codefly/token`, + otherwise. A consumer with another projection passes + `ProjectedTokenFile(path)` instead, which has no default. The host requires audience `accounts` and a token lifetime of at most 600 seconds, and verifies the pod, workload and approved build. The SDK rereads the source for every actual mint (including a module Work Context refresh), trims whitespace, and rejects an diff --git a/moduleauthority/client.go b/moduleauthority/client.go index 09ffad8..e9042d1 100644 --- a/moduleauthority/client.go +++ b/moduleauthority/client.go @@ -156,15 +156,44 @@ type ProjectedTokenSource interface { // ProjectedTokenFile reads the token from the path its projected volume is // mounted at, trimmed. An empty file is an error, never an empty token: a // projection mid-rotation must not be presented as a credential. -// The consumer supplies the mount path; there is no default. The file read -// is limited to 64 KiB, including surrounding whitespace. +// The value is the path to read; ProjectedTokenFile itself has no default. +// The file read is limited to 64 KiB, including surrounding whitespace. // // It is the same contract as sdk-go/workcontext's ProjectedTokenFile; a // consumer that already imports that module may pass that one instead. This // one exists because moduleauthority does not import sdk-go/workcontext: it // carries Work Contexts as opaque strings and reads them only through core. +// +// A module running under Codefly passes WorkloadTokenFile(), which names the +// path Codefly projected the token to wherever it runs. type ProjectedTokenFile string +// WorkloadTokenFileVariable is the environment variable that names the +// module's projected token file to a process that cannot read it at the path a +// pod mounts it at. A native `codefly run` stands in for the cell (codefly-dev/cli +// v0.1.187, #977): it projects each module workload's token to a file of its +// own and names that file here. codefly-dev/solution-runtime-go reads the same +// variable (ProjectedTokenFileEnvironmentVariable). +const WorkloadTokenFileVariable = "CODEFLY__WORKLOAD_TOKEN_FILE" + +// PodWorkloadTokenFile is the path a cell's rendered pod mounts the module's +// projected ServiceAccount token at, read-only, for audience `accounts`. +const PodWorkloadTokenFile = "/var/run/secrets/codefly/token" + +// WorkloadTokenFile is where this module's projected token is, wherever +// Codefly runs it: the file WorkloadTokenFileVariable names when the variable +// is set to a non-blank value, and PodWorkloadTokenFile otherwise. A process +// under a native run is told; a pod is not, and reads its mount. +// +// It resolves the path, never the token: the file is still read before every +// mint, because the projection rotates under a running process. +func WorkloadTokenFile() ProjectedTokenFile { + if path := strings.TrimSpace(os.Getenv(WorkloadTokenFileVariable)); path != "" { + return ProjectedTokenFile(path) + } + return ProjectedTokenFile(PodWorkloadTokenFile) +} + // ProjectedToken reads and trims the file, bounded. func (path ProjectedTokenFile) ProjectedToken() (string, error) { if strings.TrimSpace(string(path)) == "" { diff --git a/moduleauthority/workload_token_test.go b/moduleauthority/workload_token_test.go new file mode 100644 index 0000000..4a9fe4e --- /dev/null +++ b/moduleauthority/workload_token_test.go @@ -0,0 +1,56 @@ +package moduleauthority + +import ( + "os" + "path/filepath" + "testing" +) + +// A pod is told nothing and reads its mount; a native `codefly run` names the +// file it projected the token to. Without the variable the path is the pod's, +// so a module under a native run that ignored it would open a path the process +// cannot read and mint nothing. +func TestWorkloadTokenFileFollowsTheVariableAndFallsBackToTheMount(t *testing.T) { + t.Run("unset", func(t *testing.T) { + t.Setenv(WorkloadTokenFileVariable, "") + if err := os.Unsetenv(WorkloadTokenFileVariable); err != nil { + t.Fatal(err) + } + if got := WorkloadTokenFile(); got != ProjectedTokenFile(PodWorkloadTokenFile) { + t.Fatalf("WorkloadTokenFile() = %q, want the pod mount %q", got, PodWorkloadTokenFile) + } + }) + t.Run("blank", func(t *testing.T) { + t.Setenv(WorkloadTokenFileVariable, " \t") + if got := WorkloadTokenFile(); got != ProjectedTokenFile(PodWorkloadTokenFile) { + t.Fatalf("WorkloadTokenFile() = %q, want the pod mount %q", got, PodWorkloadTokenFile) + } + }) + t.Run("named", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(path, []byte(" projected-token\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(WorkloadTokenFileVariable, " "+path+" ") + source := WorkloadTokenFile() + if source != ProjectedTokenFile(path) { + t.Fatalf("WorkloadTokenFile() = %q, want the named file %q", source, path) + } + token, err := source.ProjectedToken() + if err != nil || token != "projected-token" { + t.Fatalf("ProjectedToken() = %q, %v; want the named file's token", token, err) + } + }) +} + +// The two names are contracts with other repositories, not choices made here: +// the CLI exports the variable to a native run (cli cmd/run/native_cluster.go, +// workloadTokenFileVariable) and a cell's render mounts the token at the path. +func TestWorkloadTokenNamesAreTheCodeflyContract(t *testing.T) { + if WorkloadTokenFileVariable != "CODEFLY__WORKLOAD_TOKEN_FILE" { + t.Errorf("WorkloadTokenFileVariable = %q", WorkloadTokenFileVariable) + } + if PodWorkloadTokenFile != "/var/run/secrets/codefly/token" { + t.Errorf("PodWorkloadTokenFile = %q", PodWorkloadTokenFile) + } +}