Add statusz page in k8s secret credential provider (#1811)

Follow-up change on a comment in #1335 to add a statusz page in the
credential provider service.

- [x] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
This commit is contained in:
Yufan Su
2026-09-23 18:45:57 +00:00
committed by GitHub
parent 47b67574ac
commit 32df553276
6 changed files with 292 additions and 1 deletions
@@ -128,6 +128,10 @@ func NewServer(client kubernetes.Interface, nsAuth *NamespaceAuthorizer) *Server
return &Server{client: client, nsAuth: nsAuth}
}
// Grants exposes the enforced atespace→namespace policy for /statusz. Nil
// when authorization is disabled.
func (s *Server) Grants() map[string][]string { return s.nsAuth.Grants() }
// FetchSecret resolves one ate-secret:// URI to its Secret value.
func (s *Server) FetchSecret(ctx context.Context, req *credproviderpb.FetchSecretRequest) (*credproviderpb.FetchSecretResponse, error) {
ref, err := ParseURI(req.GetUri())
@@ -22,9 +22,11 @@ package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os/signal"
"syscall"
"time"
@@ -49,6 +51,7 @@ const serviceName = "credprovider"
var (
listenAddr = pflag.String("listen-address", ":50051", "gRPC listen address")
metricsAddr = pflag.String("metrics-address", ":9090", "Prometheus/health HTTP listen address")
statusAddr = pflag.String("status-address", ":4040", "/statusz HTTP listen address; empty disables the page")
serverBundle = pflag.String("server-cred-bundle", "", "credential bundle (PEM key+chain) presented for serving TLS (required)")
clientCAFile = pflag.String("client-ca-file", "", "CA bundle that caller (injector) client certificates must chain to (required)")
// The injector is the only caller allowed to fetch secrets. Its identity
@@ -118,7 +121,8 @@ func run(ctx context.Context) error {
grpc.Creds(creds),
)
reflection.Register(srv)
credproviderpb.RegisterCredentialProviderServer(srv, NewServer(client, nsAuth))
provider := NewServer(client, nsAuth)
credproviderpb.RegisterCredentialProviderServer(srv, provider)
lis, err := (&net.ListenConfig{}).Listen(ctx, "tcp", *listenAddr)
if err != nil {
@@ -127,6 +131,25 @@ func run(ctx context.Context) error {
shutdownCtx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
// The /statusz debug page.
if *statusAddr != "" {
mux := http.NewServeMux()
mux.HandleFunc("/statusz", newStatuszHandler(provider))
statusSrv := &http.Server{Addr: *statusAddr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() {
<-shutdownCtx.Done()
_ = statusSrv.Close()
}()
go func() {
slog.InfoContext(ctx, "statusz listening", slog.String("address", *statusAddr))
// Best-effort: a bind failure is logged, not fatal.
if err := statusSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.ErrorContext(ctx, "statusz server exited", slog.Any("err", err))
}
}()
}
go func() {
<-shutdownCtx.Done()
slog.Info("shutting down")
@@ -17,6 +17,7 @@ package main
import (
"fmt"
"os"
"sort"
"sigs.k8s.io/yaml"
)
@@ -74,6 +75,23 @@ func newNamespaceAuthorizer(file namespacePolicyFile) (*NamespaceAuthorizer, err
return &NamespaceAuthorizer{allowed: allowed}, nil
}
// Grants returns the loaded policy as atespace → sorted namespaces
func (a *NamespaceAuthorizer) Grants() map[string][]string {
if a == nil {
return nil
}
out := make(map[string][]string, len(a.allowed))
for atespace, namespaces := range a.allowed {
list := make([]string, 0, len(namespaces))
for ns := range namespaces {
list = append(list, ns)
}
sort.Strings(list)
out[atespace] = list
}
return out
}
// Allowed reports whether atespace may resolve secrets in namespace. Default
// deny: an atespace absent from the mapping, or a namespace not in its list, is
// refused.
@@ -0,0 +1,112 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// The provider's /statusz page, mirroring the atenet router's: one handler
// serving an HTML dashboard by default and JSON under ?format=json (or
// Accept: application/json). It shows the provider's configuration as the
// running process holds it — notably the authorization policy loaded at
// startup, which the ConfigMap on disk may have since drifted from. Names
// and configuration only, never secret values.
package main
import (
"encoding/json"
"html/template"
"net/http"
"os"
"strings"
"github.com/spf13/pflag"
"github.com/agent-substrate/substrate/internal/version"
)
// statusContext is the /statusz payload; the JSON form is the contract tests
// and tooling read, the HTML form renders the same data.
type statusContext struct {
Version string `json:"version"`
ProviderName string `json:"provider_name"`
Args string `json:"args"`
Flags map[string]string `json:"flags"`
InjectorSAN string `json:"injector_san"`
PolicyGrants map[string][]string `json:"policy_grants"`
}
// newStatuszHandler serves /statusz over the provider's state.
func newStatuszHandler(srv *Server) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
flags := make(map[string]string)
pflag.VisitAll(func(f *pflag.Flag) { flags[f.Name] = f.Value.String() })
data := statusContext{
Version: version.String(),
ProviderName: ProviderName,
Args: strings.Join(os.Args, " "),
Flags: flags,
InjectorSAN: *injectorIdentity,
PolicyGrants: srv.Grants(),
}
if strings.Contains(req.Header.Get("Accept"), "application/json") || req.URL.Query().Get("format") == "json" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(data)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = statuszTemplate.Execute(w, data)
}
}
var statuszTemplate = template.Must(template.New("statusz").Parse(`<!DOCTYPE html>
<html>
<head><title>k8s-credential-provider statusz</title>
<style>
body { font-family: monospace; margin: 2em; }
table { border-collapse: collapse; margin-bottom: 2em; }
th, td { border: 1px solid #999; padding: 4px 8px; text-align: left; }
th { background: #eee; }
</style>
</head>
<body>
<h1>k8s-credential-provider</h1>
<p>version: {{.Version}}<br>
provider: {{.ProviderName}}<br>
required caller SAN: {{.InjectorSAN}}<br>
args: {{.Args}}</p>
<h2>Namespace policy (as loaded at startup)</h2>
{{if .PolicyGrants}}
<table>
<tr><th>atespace</th><th>allowed namespaces</th></tr>
{{range $atespace, $namespaces := .PolicyGrants}}
<tr><td>{{$atespace}}</td><td>{{range $namespaces}}{{.}} {{end}}</td></tr>
{{end}}
</table>
{{else}}
<p><b>authorization disabled</b> — every namespace is allowed (dev only).</p>
{{end}}
<h2>Flags</h2>
<table>
<tr><th>flag</th><th>value</th></tr>
{{range $name, $value := .Flags}}
<tr><td>{{$name}}</td><td>{{$value}}</td></tr>
{{end}}
</table>
</body>
</html>
`))
@@ -0,0 +1,131 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
"github.com/agent-substrate/substrate/pkg/proto/credproviderpb"
)
func TestGrants(t *testing.T) {
authz, err := newNamespaceAuthorizer(namespacePolicyFile{
Policies: []atespaceNamespacePolicy{
{Atespace: "team-a", AllowedNamespaces: []string{"ns2", "ns1"}},
{Atespace: "team-b", AllowedNamespaces: []string{"ns3"}},
},
})
if err != nil {
t.Fatalf("newNamespaceAuthorizer: %v", err)
}
want := map[string][]string{
"team-a": {"ns1", "ns2"}, // sorted, not input order
"team-b": {"ns3"},
}
if got := authz.Grants(); !reflect.DeepEqual(got, want) {
t.Errorf("Grants() = %v, want %v", got, want)
}
var disabled *NamespaceAuthorizer
if got := disabled.Grants(); got != nil {
t.Errorf("nil authorizer Grants() = %v, want nil", got)
}
}
// statuszServer builds a provider that has resolved a secret, so the tests
// can assert the value never surfaces on the page.
func statuszServer(t *testing.T) (*Server, string) {
t.Helper()
const secretValue = "statusz-must-never-show-this"
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "example-api", Namespace: "ns1"},
Data: map[string][]byte{"token": []byte(secretValue)},
}
authz, err := newNamespaceAuthorizer(namespacePolicyFile{
Policies: []atespaceNamespacePolicy{{Atespace: "team-a", AllowedNamespaces: []string{"ns1"}}},
})
if err != nil {
t.Fatalf("newNamespaceAuthorizer: %v", err)
}
srv := NewServer(fake.NewSimpleClientset(secret), authz)
if _, err := srv.FetchSecret(context.Background(), &credproviderpb.FetchSecretRequest{
Uri: "ate-secret://k8s.io/default/ns1/example-api/token",
ActorSpiffeId: "spiffe://substrate-actor.local/atespace/team-a/actor/my-actor",
}); err != nil {
t.Fatalf("FetchSecret: %v", err)
}
return srv, secretValue
}
func TestStatuszJSON(t *testing.T) {
srv, secretValue := statuszServer(t)
rec := httptest.NewRecorder()
newStatuszHandler(srv)(rec, httptest.NewRequest(http.MethodGet, "/statusz?format=json", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var data statusContext
if err := json.Unmarshal(rec.Body.Bytes(), &data); err != nil {
t.Fatalf("decoding statusz JSON: %v (body %q)", err, rec.Body.String())
}
if !reflect.DeepEqual(data.PolicyGrants, map[string][]string{"team-a": {"ns1"}}) {
t.Errorf("policy_grants = %v, want team-a → ns1", data.PolicyGrants)
}
if data.InjectorSAN != *injectorIdentity {
t.Errorf("injector_san = %q, want %q", data.InjectorSAN, *injectorIdentity)
}
if data.Version == "" || data.ProviderName != ProviderName {
t.Errorf("version = %q, provider_name = %q: want non-empty version and provider %q", data.Version, data.ProviderName, ProviderName)
}
if strings.Contains(rec.Body.String(), secretValue) {
t.Fatal("statusz JSON contains the secret value")
}
}
// The page shows configuration only; a resolved secret value appearing in
// either rendering would be an exfiltration path.
func TestStatuszHTMLNeverShowsSecret(t *testing.T) {
srv, secretValue := statuszServer(t)
rec := httptest.NewRecorder()
newStatuszHandler(srv)(rec, httptest.NewRequest(http.MethodGet, "/statusz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Errorf("Content-Type = %q, want text/html", rec.Header().Get("Content-Type"))
}
if !strings.Contains(body, "team-a") {
t.Errorf("HTML page is missing the policy grants")
}
if strings.Contains(body, secretValue) {
t.Fatal("statusz HTML contains the secret value")
}
}
@@ -75,6 +75,7 @@ spec:
args:
- "--listen-address=:50051"
- "--metrics-address=:9090"
- "--status-address=:4040"
# Serve with the pod's servicedns identity (SAN k8s-credential-provider.ate-system.svc)
# and require the injector to present a podidentity client cert whose chain
# verifies against the trust bundle. The provider additionally pins the
@@ -91,6 +92,8 @@ spec:
containerPort: 50051
- name: metrics
containerPort: 9090
- name: statusz
containerPort: 4040
readinessProbe:
httpGet:
path: /readyz