362 lines
14 KiB
Go
362 lines
14 KiB
Go
//go:build integration
|
|
|
|
/*
|
|
Copyright 2026.
|
|
|
|
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 openbao_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
bao "github.com/openbao/openbao/api/v2"
|
|
authenticationv1 "k8s.io/api/authentication/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/clientcmd"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
|
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
|
|
|
"git.ddupan.top/panxiao81/ayatori/internal/infra/openbao"
|
|
)
|
|
|
|
const (
|
|
unrelatedAuthNamespace = "bao-unrelated"
|
|
tokenRequestRole = "request-openbao-token"
|
|
authNamespace = "bao-controller"
|
|
authAudience = "openbao"
|
|
)
|
|
|
|
// Bao 容器通过 Docker bridge 访问这个仅转发 TokenReview 的临时入口。
|
|
// 上游仍是带 CA 验证的真实 envtest API;不模拟 JWT 签名、audience 或 RBAC 判定。
|
|
func tokenReviewEndpoint(t *testing.T, config *rest.Config) (string, string) {
|
|
t.Helper()
|
|
upstream, err := url.Parse(config.Host)
|
|
if err != nil {
|
|
t.Fatal("invalid envtest address")
|
|
}
|
|
transport, err := rest.TransportFor(rest.AnonymousClientConfig(config))
|
|
if err != nil {
|
|
t.Fatal("cannot construct TokenReview transport")
|
|
}
|
|
proxy := httputil.NewSingleHostReverseProxy(upstream)
|
|
proxy.Transport = transport
|
|
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) { w.WriteHeader(http.StatusBadGateway) }
|
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost || r.URL.Path != "/apis/authentication.k8s.io/v1/tokenreviews" {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
proxy.ServeHTTP(w, r)
|
|
}))
|
|
if err := server.Listener.Close(); err != nil {
|
|
t.Fatal("cannot replace fixture listener")
|
|
}
|
|
server.Listener, err = net.Listen("tcp", "0.0.0.0:0")
|
|
if err != nil {
|
|
t.Fatal("cannot expose TokenReview fixture")
|
|
}
|
|
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
|
defer cancel()
|
|
output, err := exec.CommandContext(ctx, "docker", "network", "inspect", "bridge", "--format",
|
|
`{{(index .IPAM.Config 0).Gateway}}`).Output()
|
|
if err != nil {
|
|
t.Fatal("cannot locate fixture Docker bridge")
|
|
}
|
|
gateway := strings.TrimSpace(string(output))
|
|
if net.ParseIP(gateway) == nil {
|
|
t.Fatal("invalid fixture bridge gateway")
|
|
}
|
|
certificate, caPEM := tokenReviewCertificate(t, net.ParseIP(gateway))
|
|
server.TLS = &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
|
|
server.StartTLS()
|
|
t.Cleanup(server.Close)
|
|
port := server.Listener.Addr().(*net.TCPAddr).Port
|
|
return "https://" + net.JoinHostPort(gateway, strconv.Itoa(port)), caPEM
|
|
}
|
|
|
|
func tokenReviewCertificate(t *testing.T, address net.IP) (tls.Certificate, string) {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
t.Fatal("cannot create fixture TLS key")
|
|
}
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
NotBefore: time.Now().Add(-time.Minute),
|
|
NotAfter: time.Now().Add(time.Hour),
|
|
IPAddresses: []net.IP{address},
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
IsCA: true, BasicConstraintsValid: true,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatal("cannot create fixture TLS certificate")
|
|
}
|
|
certificate := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
|
return certificate, string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
|
|
}
|
|
|
|
type kubernetesAuthFixture struct {
|
|
config *rest.Config
|
|
controllerConfig *rest.Config
|
|
admin *kubernetes.Clientset
|
|
controller *kubernetes.Clientset
|
|
grant func()
|
|
requestToken func(string, []string) string
|
|
}
|
|
|
|
func newKubernetesAuthFixture(t *testing.T) *kubernetesAuthFixture {
|
|
t.Helper()
|
|
environment := &envtest.Environment{}
|
|
config, err := environment.Start()
|
|
if err != nil {
|
|
t.Fatal("cannot start authentication API fixture", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := environment.Stop(); err != nil {
|
|
t.Error("cannot stop authentication API fixture")
|
|
}
|
|
})
|
|
clientset, err := kubernetes.NewForConfig(config)
|
|
if err != nil {
|
|
t.Fatal("cannot construct fixture API client")
|
|
}
|
|
ctx := t.Context()
|
|
for _, namespace := range []string{authNamespace, unrelatedAuthNamespace} {
|
|
if _, err := clientset.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{Name: namespace}, metav1.CreateOptions{}); err != nil {
|
|
t.Fatal("cannot create fixture namespace")
|
|
}
|
|
if _, err := clientset.CoreV1().ServiceAccounts(namespace).Create(ctx, &corev1.ServiceAccount{Name: authRole}, metav1.CreateOptions{}); err != nil {
|
|
t.Fatal("cannot create fixture service account")
|
|
}
|
|
}
|
|
// 集群外身份只有指定 SA 的 TokenRequest 权限,不使用 envtest 管理员凭据运行会话。
|
|
user, err := environment.AddUser(envtest.User{Name: "systemd-controller"}, config)
|
|
if err != nil {
|
|
t.Fatal("cannot create external controller identity")
|
|
}
|
|
kubeconfig, err := user.KubeConfig()
|
|
if err != nil {
|
|
t.Fatal("cannot build external kubeconfig")
|
|
}
|
|
externalConfig, err := clientcmd.RESTConfigFromKubeConfig(kubeconfig)
|
|
if err != nil {
|
|
t.Fatal("cannot load external kubeconfig")
|
|
}
|
|
if _, err := clientset.RbacV1().Roles(authNamespace).Create(ctx, &rbacv1.Role{
|
|
Name: tokenRequestRole,
|
|
Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"serviceaccounts/token"},
|
|
ResourceNames: []string{authRole}, Verbs: []string{"create"}}},
|
|
}, metav1.CreateOptions{}); err != nil {
|
|
t.Fatal("cannot create TokenRequest Role")
|
|
}
|
|
permission := &rbacv1.RoleBinding{
|
|
Name: tokenRequestRole,
|
|
RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "Role", Name: tokenRequestRole},
|
|
Subjects: []rbacv1.Subject{{Kind: "User", APIGroup: rbacv1.GroupName, Name: "systemd-controller"}},
|
|
}
|
|
grant := func() {
|
|
t.Helper()
|
|
if _, err := clientset.RbacV1().RoleBindings(authNamespace).Create(ctx, permission.DeepCopy(), metav1.CreateOptions{}); err != nil {
|
|
t.Fatal("cannot grant TokenRequest permission")
|
|
}
|
|
}
|
|
grant()
|
|
externalClient, err := kubernetes.NewForConfig(externalConfig)
|
|
if err != nil {
|
|
t.Fatal("cannot construct restricted controller client")
|
|
}
|
|
for _, target := range []struct{ namespace, name string }{{unrelatedAuthNamespace, authRole}, {authNamespace, "another-account"}} {
|
|
_, err := externalClient.CoreV1().ServiceAccounts(target.namespace).CreateToken(ctx, target.name,
|
|
&authenticationv1.TokenRequest{Spec: authenticationv1.TokenRequestSpec{Audiences: []string{authAudience}}}, metav1.CreateOptions{})
|
|
if !apierrors.IsForbidden(err) {
|
|
t.Fatal("TokenRequest escaped Role namespace/resourceNames restriction")
|
|
}
|
|
}
|
|
if _, err := clientset.RbacV1().ClusterRoleBindings().Create(ctx, &rbacv1.ClusterRoleBinding{
|
|
Name: "bao-fixture-reviewer",
|
|
RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: "system:auth-delegator"},
|
|
Subjects: []rbacv1.Subject{{Kind: "ServiceAccount", Namespace: authNamespace, Name: authRole}},
|
|
}, metav1.CreateOptions{}); err != nil {
|
|
t.Fatal("cannot authorize fixture TokenReview")
|
|
}
|
|
requestToken := func(namespace string, audiences []string) string {
|
|
t.Helper()
|
|
response, err := clientset.CoreV1().ServiceAccounts(namespace).CreateToken(ctx, authRole,
|
|
&authenticationv1.TokenRequest{Spec: authenticationv1.TokenRequestSpec{Audiences: audiences}}, metav1.CreateOptions{})
|
|
if err != nil {
|
|
t.Fatal("cannot issue fixture service account token")
|
|
}
|
|
return response.Status.Token
|
|
}
|
|
|
|
return &kubernetesAuthFixture{
|
|
config: config, controllerConfig: externalConfig, admin: clientset, controller: externalClient,
|
|
grant: grant, requestToken: requestToken,
|
|
}
|
|
}
|
|
|
|
const authRole = "controller"
|
|
|
|
var testIdentity = openbao.KubernetesIdentity{Namespace: authNamespace, ServiceAccount: authRole, Audience: authAudience}
|
|
|
|
func waitForAuthentication(t *testing.T, check func() bool) {
|
|
t.Helper()
|
|
deadline := time.NewTimer(20 * time.Second)
|
|
defer deadline.Stop()
|
|
for !check() {
|
|
select {
|
|
case <-deadline.C:
|
|
t.Fatal("authentication condition timed out")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
func startSession(t *testing.T, session interface{ Start(context.Context) error }) context.CancelFunc {
|
|
t.Helper()
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
done := make(chan error, 1)
|
|
go func() { done <- session.Start(ctx) }()
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(20 * time.Second):
|
|
t.Error("authentication did not stop")
|
|
}
|
|
})
|
|
return cancel
|
|
}
|
|
|
|
// 此处验证公共认证会话与 Database 凭据适配器的跨层集成。
|
|
func TestKubernetesSessionWithRealTokenReview(t *testing.T) {
|
|
api := newKubernetesAuthFixture(t)
|
|
ctx := t.Context()
|
|
root := baoFixture(t)
|
|
if err := root.Sys().EnableAuthWithOptionsWithContext(ctx, "kubernetes", &bao.EnableAuthOptions{Type: "kubernetes"}); err != nil {
|
|
t.Fatal("cannot enable fixture Kubernetes auth")
|
|
}
|
|
reviewerToken := api.requestToken(authNamespace, nil)
|
|
reviewURL, reviewCA := tokenReviewEndpoint(t, api.config)
|
|
if _, err := root.Logical().WriteWithContext(ctx, "auth/kubernetes/config", map[string]any{
|
|
"kubernetes_host": reviewURL,
|
|
"kubernetes_ca_cert": reviewCA,
|
|
"token_reviewer_jwt": reviewerToken,
|
|
"disable_local_ca_jwt": true,
|
|
}); err != nil {
|
|
t.Fatal("cannot configure fixture TokenReview:", strings.NewReplacer(reviewerToken, "[REDACTED]", fixtureToken, "[REDACTED]").Replace(err.Error()))
|
|
}
|
|
if err := root.Sys().PutPolicyWithContext(ctx, authRole, `path "secret/data/applications/*" { capabilities = ["create", "update", "read"] }`); err != nil {
|
|
t.Fatal("cannot configure fixture credential policy")
|
|
}
|
|
if _, err := root.Logical().WriteWithContext(ctx, "auth/kubernetes/role/controller", map[string]any{
|
|
"bound_service_account_names": []string{authRole},
|
|
"bound_service_account_namespaces": []string{authNamespace},
|
|
"audience": authAudience,
|
|
"token_policies": []string{authRole},
|
|
"token_ttl": "3s", "token_max_ttl": "60s",
|
|
}); err != nil {
|
|
t.Fatal("cannot configure fixture auth role")
|
|
}
|
|
client := fixtureClient(t, root.Address())
|
|
manager, err := ctrl.NewManager(api.controllerConfig, ctrl.Options{Metrics: metricsserver.Options{BindAddress: "0"}, HealthProbeBindAddress: "0"})
|
|
if err != nil {
|
|
t.Fatal("cannot construct external controller manager")
|
|
}
|
|
session, err := openbao.NewKubernetesSession(client, manager.GetClient(), "kubernetes", authRole, testIdentity)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Add(session); err != nil {
|
|
t.Fatal("cannot register authentication lifecycle")
|
|
}
|
|
cancel := startSession(t, manager)
|
|
waitForAuthentication(t, session.Ready)
|
|
store := fixtureStore(t, client)
|
|
if err := store.Create(ctx, credentialPath, fixtureCredential(t)); err != nil {
|
|
t.Fatal("Kubernetes identity cannot create scoped credential", err)
|
|
}
|
|
initialToken := client.Token()
|
|
// 超过初始 TTL 后同一 token 仍可用,证明发生真实 renew-self,而非只登录一次。
|
|
start := time.Now()
|
|
waitForAuthentication(t, func() bool { return time.Since(start) > 4*time.Second })
|
|
if client.Token() != initialToken {
|
|
t.Fatal("token was replaced before renewal could be verified")
|
|
}
|
|
if _, err := client.Auth().Token().LookupSelfWithContext(ctx); err != nil {
|
|
t.Fatal("short-lived token was not renewed")
|
|
}
|
|
for _, invalid := range []struct {
|
|
namespace string
|
|
audience string
|
|
}{
|
|
{unrelatedAuthNamespace, authAudience}, {authNamespace, "wrong-audience"},
|
|
} {
|
|
// 用相同真实登录入口直接确认 namespace/audience 拒绝,不依赖定时轮询推断。
|
|
if _, err := root.Logical().WriteWithContext(ctx, "auth/kubernetes/login", map[string]any{
|
|
"role": authRole, "jwt": api.requestToken(invalid.namespace, []string{invalid.audience}),
|
|
}); err == nil {
|
|
t.Fatal("invalid Kubernetes identity was accepted")
|
|
}
|
|
}
|
|
if err := api.admin.RbacV1().RoleBindings(authNamespace).Delete(ctx, tokenRequestRole, metav1.DeleteOptions{}); err != nil {
|
|
t.Fatal("cannot revoke TokenRequest permission")
|
|
}
|
|
if err := root.Auth().Token().RevokeOrphanWithContext(ctx, client.Token()); err != nil {
|
|
t.Fatal("cannot revoke fixture OpenBao token")
|
|
}
|
|
waitForAuthentication(t, func() bool { return !session.Ready() && client.Token() == "" })
|
|
denied, err := api.controller.CoreV1().ServiceAccounts(authNamespace).CreateToken(ctx, authRole,
|
|
&authenticationv1.TokenRequest{Spec: authenticationv1.TokenRequestSpec{Audiences: []string{authAudience}}}, metav1.CreateOptions{})
|
|
if !apierrors.IsForbidden(err) || (denied != nil && denied.Status.Token != "") {
|
|
t.Fatal("revoked caller still obtained a token")
|
|
}
|
|
api.grant()
|
|
waitForAuthentication(t, session.Ready)
|
|
if client.Token() == initialToken {
|
|
t.Fatal("reauthentication reused revoked OpenBao token")
|
|
}
|
|
if _, err := store.Read(ctx, credentialPath); err != nil {
|
|
t.Fatal("credential access did not recover", err)
|
|
}
|
|
cancel()
|
|
waitForAuthentication(t, func() bool { return !session.Ready() && client.Token() == "" })
|
|
}
|