Skip to content

Commit ae93e77

Browse files
committed
Add support for access tokens for isolated environments
To run tc-logview without gcloud auth available or permanent service account key, one could issue OAuth2 access token with gcloud and impersonate some limited service account `TC_LOGVIEW_ACCESS_TOKEN` would be used if present
1 parent c5c8da4 commit ae93e77

10 files changed

Lines changed: 206 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## v1.3.0 - 2026-05-28
4+
5+
### Added
6+
7+
- Pre-issued OAuth2 access tokens via the `TC_LOGVIEW_ACCESS_TOKEN` env var. Intended for containers/agents that should run without access to the host's `gcloud` session or a long-lived service account key. The host mints a short-lived, SA-scoped token (e.g. `gcloud auth print-access-token --impersonate-service-account=<SA>`) and injects only the token; tc-logview itself does not impersonate or refresh.
8+
- Auth precedence is now `TC_LOGVIEW_ACCESS_TOKEN` > `key_path` > ADC; the active mode is logged under `-v` as `token`, `key_file`, or `ADC`. Client-create and query errors are tagged with the active mode for diagnosability.
9+
- Expired/invalid token errors append a one-line hint pointing at the re-mint command.
10+
11+
### Changed
12+
13+
- `config init` template's authentication comment block now documents all three modes and the priority order.
14+
- README Setup section gains Option C describing the host-mints-token / container-consumes-token flow.
15+
316
## v1.2.0 - 2026-05-28
417

518
### Added

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ environments:
6868

6969
### 2. Authenticate to GCP
7070

71-
Choose one of the two options below per environment. In the example config above, `fx-ci`, `community-tc`, and `staging` use service account keys (Option B); `dev` uses ADC (Option A).
71+
Choose one of the three options below. Options A and B are configured per environment in `config.yaml`; Option C is selected at runtime via the `TC_LOGVIEW_ACCESS_TOKEN` env var and overrides whatever the config says. In the example above, `fx-ci`, `community-tc`, and `staging` use service account keys (Option B); `dev` uses ADC (Option A).
7272

7373
#### Option A — Application Default Credentials (recommended for local use)
7474

@@ -86,6 +86,26 @@ Place your service account JSON key in `~/.config/tc-logview/keys/` and set `key
8686

8787
This mode is what you want inside an untrusted/containerized environment where you do not want to share your personal `gcloud` session.
8888

89+
#### Option C — Injected access token (recommended for containers / agents)
90+
91+
For containers running untrusted code (agents, CI jobs, ephemeral sandboxes), prefer a pre-issued short-lived bearer token over a long-lived service account key. The host impersonates a low-privilege service account once and passes only the resulting access token into the container.
92+
93+
```bash
94+
# On the host (requires roles/iam.serviceAccountTokenCreator on the SA):
95+
TOKEN=$(gcloud auth print-access-token \
96+
--impersonate-service-account=tc-log-reader@<PROJECT>.iam.gserviceaccount.com)
97+
98+
# Run tc-logview with the token in the container's env:
99+
docker run --rm \
100+
-e TC_LOGVIEW_ACCESS_TOKEN="$TOKEN" \
101+
-e TASKCLUSTER_ROOT_URL=https://firefox-ci-tc.services.mozilla.com \
102+
tc-logview-image query --type worker-stopped --limit 5
103+
```
104+
105+
When `TC_LOGVIEW_ACCESS_TOKEN` is set it takes priority over `key_path` and ADC. The container never sees the host's `gcloud` session, never calls the IAM Credentials API, and cannot mint or refresh tokens. Worst-case leakage is one access token, valid only for its TTL (~1h).
106+
107+
Re-mint and re-inject the token before it expires for long-running containers. An expired token surfaces as a `401` from the query path with a hint pointing back at the re-mint command.
108+
89109
### 3. Sync references
90110

91111
```bash

cmd/config_init.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import (
55
"os"
66
"path/filepath"
77

8-
"github.com/taskcluster/tc-logview/internal/config"
98
"github.com/spf13/cobra"
9+
"github.com/taskcluster/tc-logview/internal/config"
1010
)
1111

1212
var forceInit bool
@@ -36,9 +36,12 @@ var configInitCmd = &cobra.Command{
3636

3737
example := `# tc-logview configuration
3838
#
39-
# Authentication:
39+
# Authentication (priority: TC_LOGVIEW_ACCESS_TOKEN > key_path > ADC):
40+
# - Export ` + "`TC_LOGVIEW_ACCESS_TOKEN`" + ` to use a pre-issued OAuth2 bearer
41+
# token (short-lived; ideal for containers/agents — host mints via
42+
# ` + "`gcloud auth print-access-token --impersonate-service-account=<SA>`" + `).
4043
# - Set ` + "`key_path`" + ` to use a service account JSON key
41-
# (intended for untrusted/containerized environments).
44+
# (long-lived; intended for environments that cannot refresh tokens).
4245
# - Omit ` + "`key_path`" + ` to use Application Default Credentials
4346
# (run ` + "`gcloud auth application-default login`" + ` once on your machine).
4447
#

cmd/query.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,11 @@ func runQuery(cmd *cobra.Command, args []string) error {
157157
if skipCluster {
158158
projectID = env.CloudSQLProject()
159159
}
160-
logInfo("%s", authModeMessage(projectID, env.KeyPath))
161-
client, err := gcp.NewClient(ctx, projectID, env.KeyPath)
160+
auth := resolveAuth(env)
161+
logInfo("%s", authModeMessage(projectID, auth))
162+
client, err := gcp.NewClient(ctx, projectID, auth)
162163
if err != nil {
163-
if hint := adcHintIfMissing(err, env.KeyPath); hint != "" {
164+
if hint := authHint(err, auth); hint != "" {
164165
return fmt.Errorf("creating GCP client: %w\n%s", err, hint)
165166
}
166167
return fmt.Errorf("creating GCP client: %w", err)
@@ -170,7 +171,11 @@ func runQuery(cmd *cobra.Command, args []string) error {
170171
logInfo("Querying GCP Cloud Logging...")
171172
result, err := client.Query(ctx, filterStr, queryLimit+queryOffset)
172173
if err != nil {
173-
return fmt.Errorf("querying logs: %w", err)
174+
wrapped := fmt.Errorf("querying logs (auth=%s): %w", gcp.AuthModeLabel(auth), err)
175+
if hint := authHint(err, auth); hint != "" {
176+
return fmt.Errorf("%w\n%s", wrapped, hint)
177+
}
178+
return wrapped
174179
}
175180

176181
rawEntries = result.Entries
@@ -304,10 +309,11 @@ func runQuery(cmd *cobra.Command, args []string) error {
304309
// Query GCP if not cached
305310
if rawEntries == nil {
306311
ctx := context.Background()
307-
logInfo("%s", authModeMessage(env.ProjectID, env.KeyPath))
308-
client, err := gcp.NewClient(ctx, env.ProjectID, env.KeyPath)
312+
auth := resolveAuth(env)
313+
logInfo("%s", authModeMessage(env.ProjectID, auth))
314+
client, err := gcp.NewClient(ctx, env.ProjectID, auth)
309315
if err != nil {
310-
if hint := adcHintIfMissing(err, env.KeyPath); hint != "" {
316+
if hint := authHint(err, auth); hint != "" {
311317
return fmt.Errorf("creating GCP client: %w\n%s", err, hint)
312318
}
313319
return fmt.Errorf("creating GCP client: %w", err)
@@ -317,7 +323,11 @@ func runQuery(cmd *cobra.Command, args []string) error {
317323
logInfo("Querying GCP Cloud Logging...")
318324
result, err := client.Query(ctx, filterStr, queryLimit+queryOffset)
319325
if err != nil {
320-
return fmt.Errorf("querying logs: %w", err)
326+
wrapped := fmt.Errorf("querying logs (auth=%s): %w", gcp.AuthModeLabel(auth), err)
327+
if hint := authHint(err, auth); hint != "" {
328+
return fmt.Errorf("%w\n%s", wrapped, hint)
329+
}
330+
return wrapped
321331
}
322332

323333
rawEntries = result.Entries
@@ -374,7 +384,7 @@ func runQuery(cmd *cobra.Command, args []string) error {
374384
}
375385

376386
// Add message field when --filter is used or there are just two columns (ts, service)
377-
if (queryFilter != "" || len(types) == 0 ) && !slices.Contains(fieldNames, "message") {
387+
if (queryFilter != "" || len(types) == 0) && !slices.Contains(fieldNames, "message") {
378388
fieldNames = append(fieldNames, "message")
379389
}
380390

cmd/root.go

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@ import (
55
"os"
66
"strings"
77

8-
"github.com/taskcluster/tc-logview/internal/config"
98
"github.com/spf13/cobra"
9+
"github.com/taskcluster/tc-logview/internal/config"
10+
"github.com/taskcluster/tc-logview/internal/gcp"
1011
)
1112

13+
// accessTokenEnv is the env var that injects a pre-issued OAuth2 bearer
14+
// token into tc-logview. Set by host orchestration (e.g. a wrapper script
15+
// running `gcloud auth print-access-token --impersonate-service-account=...`)
16+
// to give a container short-lived, SA-scoped credentials without mounting
17+
// the host's gcloud session.
18+
const accessTokenEnv = "TC_LOGVIEW_ACCESS_TOKEN"
19+
1220
var (
1321
envFlag string
1422
verbose bool
@@ -54,33 +62,52 @@ func logInfo(format string, args ...any) {
5462
}
5563
}
5664

65+
// resolveAuth assembles the AuthConfig for the current invocation by combining
66+
// the env-var token (if present) with the per-env key_path from config.
67+
func resolveAuth(env *config.Environment) gcp.AuthConfig {
68+
return gcp.AuthConfig{
69+
KeyPath: env.KeyPath,
70+
AccessToken: os.Getenv(accessTokenEnv),
71+
}
72+
}
73+
5774
// authModeMessage returns a human-readable description of which auth mode
5875
// the gcp client will use for the given project. Intended for stderr
5976
// diagnostics under -v.
60-
func authModeMessage(projectID, keyPath string) string {
61-
if keyPath == "" {
77+
func authModeMessage(projectID string, auth gcp.AuthConfig) string {
78+
switch {
79+
case auth.AccessToken != "":
80+
return fmt.Sprintf(
81+
"auth: using injected access token from %s (project=%s)",
82+
accessTokenEnv, projectID,
83+
)
84+
case auth.KeyPath != "":
85+
return fmt.Sprintf(
86+
"auth: using service account key file (project=%s, path=%s)",
87+
projectID, auth.KeyPath,
88+
)
89+
default:
6290
return fmt.Sprintf(
6391
"auth: no key_path configured, using application default credentials (project=%s)",
6492
projectID,
6593
)
6694
}
67-
return fmt.Sprintf(
68-
"auth: using service account key file (project=%s, path=%s)",
69-
projectID, keyPath,
70-
)
7195
}
7296

73-
// adcHintIfMissing returns a one-line hint when a GCP client-create error
74-
// indicates that Application Default Credentials are not configured AND
75-
// the user has no key_path set. Empty string in any other case.
76-
func adcHintIfMissing(err error, keyPath string) string {
77-
if err == nil || keyPath != "" {
97+
// authHint returns a one-line, mode-specific hint when a GCP error suggests
98+
// a fixable auth misconfiguration. Empty string when no hint applies.
99+
func authHint(err error, auth gcp.AuthConfig) string {
100+
if err == nil {
78101
return ""
79102
}
80-
if !strings.Contains(err.Error(), "could not find default credentials") {
81-
return ""
103+
msg := err.Error()
104+
switch {
105+
case auth.AccessToken != "" && (strings.Contains(msg, "invalid") || strings.Contains(msg, "expired") || strings.Contains(msg, "401")):
106+
return fmt.Sprintf("hint: %s may be expired (~1h TTL).\n Re-mint: gcloud auth print-access-token --impersonate-service-account=<SA>", accessTokenEnv)
107+
case auth.AccessToken == "" && auth.KeyPath == "" && strings.Contains(msg, "could not find default credentials"):
108+
return "hint: run `gcloud auth application-default login`, or set `key_path` in ~/.config/tc-logview/config.yaml"
82109
}
83-
return "hint: run `gcloud auth application-default login`, or set `key_path` in ~/.config/tc-logview/config.yaml"
110+
return ""
84111
}
85112

86113
func Execute() error {

cmd/root_test.go

Lines changed: 58 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,69 +4,110 @@ import (
44
"errors"
55
"strings"
66
"testing"
7+
8+
"github.com/taskcluster/tc-logview/internal/gcp"
79
)
810

911
func TestAuthModeMessage(t *testing.T) {
1012
tests := []struct {
1113
name string
1214
projectID string
13-
keyPath string
15+
auth gcp.AuthConfig
1416
contains []string
1517
}{
1618
{
17-
name: "empty key_path mentions ADC and project",
19+
name: "empty auth mentions ADC and project",
1820
projectID: "moz-fx-foo",
19-
keyPath: "",
21+
auth: gcp.AuthConfig{},
2022
contains: []string{"no key_path", "application default credentials", "moz-fx-foo"},
2123
},
2224
{
2325
name: "set key_path mentions key file and path",
2426
projectID: "moz-fx-bar",
25-
keyPath: "/etc/keys/x.json",
27+
auth: gcp.AuthConfig{KeyPath: "/etc/keys/x.json"},
2628
contains: []string{"service account key file", "moz-fx-bar", "/etc/keys/x.json"},
2729
},
30+
{
31+
name: "access token mentions injected token and env var name",
32+
projectID: "moz-fx-baz",
33+
auth: gcp.AuthConfig{AccessToken: "ya29.fake"},
34+
contains: []string{"injected access token", accessTokenEnv, "moz-fx-baz"},
35+
},
36+
{
37+
name: "token wins over key_path",
38+
projectID: "moz-fx-qux",
39+
auth: gcp.AuthConfig{KeyPath: "/etc/keys/x.json", AccessToken: "ya29.fake"},
40+
contains: []string{"injected access token", "moz-fx-qux"},
41+
},
2842
}
2943
for _, tt := range tests {
3044
t.Run(tt.name, func(t *testing.T) {
31-
got := authModeMessage(tt.projectID, tt.keyPath)
45+
got := authModeMessage(tt.projectID, tt.auth)
3246
for _, sub := range tt.contains {
3347
if !strings.Contains(got, sub) {
34-
t.Errorf("authModeMessage(%q,%q) = %q; missing %q",
35-
tt.projectID, tt.keyPath, got, sub)
48+
t.Errorf("authModeMessage(%q,%+v) = %q; missing %q",
49+
tt.projectID, tt.auth, got, sub)
3650
}
3751
}
3852
})
3953
}
4054
}
4155

42-
func TestAdcHintIfMissing(t *testing.T) {
43-
hint := "gcloud auth application-default login"
56+
func TestAuthHint(t *testing.T) {
57+
adcHintFragment := "gcloud auth application-default login"
58+
tokenHintFragment := "may be expired"
4459

45-
t.Run("ADC mode + matching error -> hint", func(t *testing.T) {
60+
t.Run("ADC mode + missing-creds error -> ADC hint", func(t *testing.T) {
4661
err := errors.New("google: could not find default credentials. See ...")
47-
got := adcHintIfMissing(err, "")
48-
if !strings.Contains(got, hint) {
49-
t.Errorf("expected hint containing %q, got %q", hint, got)
62+
got := authHint(err, gcp.AuthConfig{})
63+
if !strings.Contains(got, adcHintFragment) {
64+
t.Errorf("expected ADC hint containing %q, got %q", adcHintFragment, got)
5065
}
5166
})
5267

5368
t.Run("ADC mode + unrelated error -> empty", func(t *testing.T) {
5469
err := errors.New("some other failure")
55-
if got := adcHintIfMissing(err, ""); got != "" {
70+
if got := authHint(err, gcp.AuthConfig{}); got != "" {
5671
t.Errorf("expected empty hint, got %q", got)
5772
}
5873
})
5974

60-
t.Run("key_file mode + matching error -> empty (not user's problem)", func(t *testing.T) {
75+
t.Run("key_file mode + missing-creds error -> empty (not user's problem)", func(t *testing.T) {
6176
err := errors.New("google: could not find default credentials")
62-
if got := adcHintIfMissing(err, "/etc/keys/x.json"); got != "" {
77+
if got := authHint(err, gcp.AuthConfig{KeyPath: "/etc/keys/x.json"}); got != "" {
6378
t.Errorf("expected empty hint when key_path is set, got %q", got)
6479
}
6580
})
6681

6782
t.Run("nil error -> empty", func(t *testing.T) {
68-
if got := adcHintIfMissing(nil, ""); got != "" {
83+
if got := authHint(nil, gcp.AuthConfig{}); got != "" {
6984
t.Errorf("expected empty hint for nil error, got %q", got)
7085
}
7186
})
87+
88+
t.Run("token mode + 401 -> token re-mint hint", func(t *testing.T) {
89+
err := errors.New("rpc error: code = Unauthenticated desc = Request had invalid authentication credentials. Status 401")
90+
got := authHint(err, gcp.AuthConfig{AccessToken: "ya29.fake"})
91+
if !strings.Contains(got, tokenHintFragment) {
92+
t.Errorf("expected token hint containing %q, got %q", tokenHintFragment, got)
93+
}
94+
if !strings.Contains(got, accessTokenEnv) {
95+
t.Errorf("expected token hint to mention %q, got %q", accessTokenEnv, got)
96+
}
97+
})
98+
99+
t.Run("token mode + expired wording -> token hint", func(t *testing.T) {
100+
err := errors.New("oauth2: token expired and refresh token is not set")
101+
got := authHint(err, gcp.AuthConfig{AccessToken: "ya29.fake"})
102+
if !strings.Contains(got, tokenHintFragment) {
103+
t.Errorf("expected token hint containing %q, got %q", tokenHintFragment, got)
104+
}
105+
})
106+
107+
t.Run("token mode + unrelated error -> empty", func(t *testing.T) {
108+
err := errors.New("network unreachable")
109+
if got := authHint(err, gcp.AuthConfig{AccessToken: "ya29.fake"}); got != "" {
110+
t.Errorf("expected empty hint for unrelated error, got %q", got)
111+
}
112+
})
72113
}

cmd/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
)
1010

1111
// version is bumped manually with each release. Keep in sync with CHANGELOG.md.
12-
const version = "v1.2.0"
12+
const version = "v1.3.0"
1313

1414
var versionCmd = &cobra.Command{
1515
Use: "version",

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ require (
66
cloud.google.com/go/logging v1.13.2
77
github.com/spf13/cobra v1.10.2
88
github.com/tidwall/gjson v1.18.0
9+
golang.org/x/oauth2 v0.35.0
910
google.golang.org/api v0.269.0
11+
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409
12+
google.golang.org/protobuf v1.36.11
1013
gopkg.in/yaml.v3 v3.0.1
1114
)
1215

@@ -36,14 +39,11 @@ require (
3639
go.opentelemetry.io/otel/trace v1.39.0 // indirect
3740
golang.org/x/crypto v0.48.0 // indirect
3841
golang.org/x/net v0.50.0 // indirect
39-
golang.org/x/oauth2 v0.35.0 // indirect
4042
golang.org/x/sync v0.19.0 // indirect
4143
golang.org/x/sys v0.41.0 // indirect
4244
golang.org/x/text v0.34.0 // indirect
4345
golang.org/x/time v0.14.0 // indirect
4446
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
45-
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
4647
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
4748
google.golang.org/grpc v1.79.1 // indirect
48-
google.golang.org/protobuf v1.36.11 // indirect
4949
)

0 commit comments

Comments
 (0)