Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions internal/cli/cluster.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)")
}

Expand Down
25 changes: 18 additions & 7 deletions internal/cluster/token.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@ package cluster
import (
"context"
"fmt"
"time"

authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
Expand All@@ -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
Expand DownExpand Up@@ -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
}

Expand Down
75 changes: 75 additions & 0 deletions internal/cluster/token_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@ import (
"errors"
"strings"
"testing"
"time"

authenticationv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
Expand DownExpand Up@@ -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"

Expand Down
Loading