diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 76a0952a..a23631a1 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -206,10 +206,17 @@ func runClusterInfo( p.Section("Ingestor SA token") p.Field("source", tok.Source.String()) p.Field("sha256[:8]", hex.EncodeToString(hash[:8])) - if tok.ExpirationSeconds > 0 { + switch { + case !tok.ExpiresAt.IsZero(): + // The server's authoritative, policy-capped expiry (TokenRequest path). + // "~" hedges the client/server clock skew in time.Until (#4). + remaining := time.Until(tok.ExpiresAt).Round(time.Second) + p.Field("expires in", fmt.Sprintf("~%s", remaining)) + case tok.ExpirationSeconds > 0: + // No server timestamp — fall back to the requested lifetime. exp := time.Duration(tok.ExpirationSeconds) * time.Second - p.Field("expires in", fmt.Sprintf("~%s (server may cap shorter)", exp)) - } else { + p.Field("expires in", fmt.Sprintf("~%s (requested; server may cap shorter)", exp)) + default: p.Field("expires in", "never (static-secret fallback)") } diff --git a/internal/cluster/token.go b/internal/cluster/token.go index 0c9a962e..1802daed 100644 --- a/internal/cluster/token.go +++ b/internal/cluster/token.go @@ -3,6 +3,7 @@ package cluster import ( "context" "fmt" + "time" authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -21,14 +22,21 @@ type IngestorToken struct { // don't log it. Diagnostics print SHA256(Token)[:8] instead. Token string - // ExpirationSeconds matches the request — actual server-side - // expiration may be capped by cluster policy - // (--service-account-max-token-expiration on kube-apiserver). - // We don't try to parse the JWT to read its `exp` claim - // because the customer's diagnostic ("token expires in ~N - // min") is accurate enough using the requested value. + // ExpirationSeconds is the REQUESTED lifetime. The actual grant may + // be capped shorter by cluster policy + // (--service-account-max-token-expiration on kube-apiserver); see + // ExpiresAt for the authoritative value on the TokenRequest path. ExpirationSeconds int64 + // ExpiresAt is the server's AUTHORITATIVE expiry for a TokenRequest + // token — TokenRequestStatus.ExpirationTimestamp, already capped by + // cluster policy. Zero for the static-secret fallback (long-lived, + // no expiry). `cluster info` shows this real value when set, so the + // customer sees the actual remaining lifetime rather than the + // requested one (#4). No JWT parsing needed — the API tells us + // directly. + ExpiresAt time.Time + // Source records how the token was obtained — TokenRequest // (the modern path) or a static secret (the fallback for // clusters where the user can't call TokenRequest). Surfaced @@ -104,7 +112,10 @@ func MintIngestorToken( return &IngestorToken{ Token: tr.Status.Token, ExpirationSeconds: expirationSeconds, - Source: TokenSourceTokenRequest, + // The server's authoritative, policy-capped expiry — may be + // shorter than requested (#4). + ExpiresAt: tr.Status.ExpirationTimestamp.Time, + Source: TokenSourceTokenRequest, }, nil } diff --git a/internal/cluster/token_test.go b/internal/cluster/token_test.go index 64e1c602..6d046421 100644 --- a/internal/cluster/token_test.go +++ b/internal/cluster/token_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "time" authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" @@ -54,6 +55,80 @@ func TestMintIngestorToken_TokenRequest_HappyPath(t *testing.T) { } } +// The TokenRequest response's ExpirationTimestamp — the server's +// authoritative, policy-capped expiry — must be captured on ExpiresAt so +// `cluster info` shows the REAL remaining lifetime, not the requested one (#4). +func TestMintIngestorToken_CapturesServerExpiry(t *testing.T) { + const ns = "tracebloc" + // Server caps to 300s even though we request 3600 — the case #4 exists for. + capped := metav1.NewTime(time.Now().Add(300 * time.Second).UTC()) + cs := fake.NewClientset(&corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "ingestor", Namespace: ns}, + }) + cs.PrependReactor("create", "serviceaccounts", + func(action k8stesting.Action) (bool, runtime.Object, error) { + ca, ok := action.(k8stesting.CreateAction) + if !ok || ca.GetSubresource() != "token" { + return false, nil, nil + } + tr := ca.GetObject().(*authenticationv1.TokenRequest) + tr.Status.Token = "fake-token" + tr.Status.ExpirationTimestamp = capped + return true, tr, nil + }) + + tok, err := MintIngestorToken(context.Background(), cs, ns, "ingestor", 3600, nil) + if err != nil { + t.Fatalf("MintIngestorToken: %v", err) + } + if !tok.ExpiresAt.Equal(capped.Time) { + t.Errorf("ExpiresAt = %v, want the server timestamp %v", tok.ExpiresAt, capped.Time) + } + // The requested seconds are still recorded, but ExpiresAt (300s out) is the + // authoritative value the display prefers — well under the 3600 requested. + if remaining := time.Until(tok.ExpiresAt); remaining > 310*time.Second { + t.Errorf("ExpiresAt should reflect the 300s server cap, got %v remaining", remaining) + } +} + +// The static-secret fallback has no expiry — ExpiresAt stays zero so the +// display reads "never", not a bogus "expires in ~0s". +func TestMintIngestorToken_StaticSecretHasNoExpiresAt(t *testing.T) { + const ns = "tracebloc" + staticSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ingestor-token-x", + Namespace: ns, + Annotations: map[string]string{corev1.ServiceAccountNameKey: "ingestor"}, + }, + Type: corev1.SecretTypeServiceAccountToken, + Data: map[string][]byte{"token": []byte("static-tok")}, + } + cs := fake.NewClientset( + &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "ingestor", Namespace: ns}}, + staticSecret, + ) + cs.PrependReactor("create", "serviceaccounts", + func(action k8stesting.Action) (bool, runtime.Object, error) { + if ca, ok := action.(k8stesting.CreateAction); ok && ca.GetSubresource() == "token" { + return true, nil, apierrors.NewForbidden( + corev1.Resource("serviceaccounts/token"), "ingestor", errors.New("denied")) + } + return false, nil, nil + }) + + tok, err := MintIngestorToken(context.Background(), cs, ns, "ingestor", 600, nil) + if err != nil { + t.Fatalf("MintIngestorToken: %v", err) + } + if tok.Source != TokenSourceStaticSecret { + t.Fatalf("Source = %v, want static-secret", tok.Source) + } + if !tok.ExpiresAt.IsZero() { + t.Errorf("static-secret token should have a zero ExpiresAt, got %v", tok.ExpiresAt) + } +} + func TestMintIngestorToken_FallsBackToStaticSecret(t *testing.T) { const ns = "tracebloc"