mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-10-02 03:24:42 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user