Files
postgresql-tenant-operator/internal/openbao/credentials.go
T

134 lines
4.5 KiB
Go

/*
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 supplies credentials using one shared client and Kubernetes identity.
package openbao
import (
"context"
"errors"
"sync"
"time"
api "git.ddupan.top/panxiao81/postgresql-tenant-operator/api/v1alpha1"
"git.ddupan.top/panxiao81/postgresql-tenant-operator/internal/instance"
kubernetesauth "github.com/openbao/openbao/api/auth/kubernetes/v2"
bao "github.com/openbao/openbao/api/v2"
)
// Credentials owns authentication; database connections never depend on this client.
type Credentials struct {
client *bao.Client
mount string
authenticate func(context.Context) (*bao.Secret, error)
mu sync.Mutex
expires time.Time
}
func NewCredentials(client *bao.Client, mount, authMount, role string) *Credentials {
source := &Credentials{client: client, mount: mount}
source.authenticate = func(ctx context.Context) (*bao.Secret, error) {
// Constructing auth here rereads the projected JWT after Kubernetes rotates it.
auth, err := kubernetesauth.NewKubernetesAuth(role, kubernetesauth.WithMountPath(authMount))
if err != nil {
return nil, instance.Failure{Reason: api.ReasonAuthenticationFailed, Operation: "read Kubernetes identity"}
}
return client.Auth().Login(ctx, auth)
}
return source
}
func (s *Credentials) login(ctx context.Context) error {
secret, err := s.authenticate(ctx)
if err != nil {
return classify("authenticate to OpenBao", err, true)
}
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" || secret.Auth.LeaseDuration <= 0 {
return instance.Failure{Reason: api.ReasonAuthenticationFailed, Operation: "OpenBao returned no leased identity"}
}
s.client.SetToken(secret.Auth.ClientToken)
ttl := time.Duration(secret.Auth.LeaseDuration) * time.Second
s.expires = time.Now().Add(ttl - ttl/10)
return nil
}
func (s *Credentials) Read(ctx context.Context, reference api.OpenBaoSecretReference) (instance.Credentials, error) {
// Login and reads share a lock so concurrent first-use requests cannot race token replacement.
s.mu.Lock()
defer s.mu.Unlock()
if !time.Now().Before(s.expires) {
if err := s.login(ctx); err != nil {
return instance.Credentials{}, err
}
}
secret, err := s.client.KVv2(s.mount).Get(ctx, reference.Path)
var response *bao.ResponseError
if errors.As(err, &response) && response.StatusCode == 403 {
// A token may be revoked before its TTL. Reauthenticate once, never loop on denied policies.
s.expires = time.Time{}
if err := s.login(ctx); err != nil {
return instance.Credentials{}, err
}
secret, err = s.client.KVv2(s.mount).Get(ctx, reference.Path)
}
if err != nil {
return instance.Credentials{}, classify("read administrative credential", err, false)
}
usernameKey, passwordKey := reference.UsernameKey, reference.PasswordKey
if usernameKey == "" {
usernameKey = "username"
}
if passwordKey == "" {
passwordKey = "password"
}
if secret == nil {
return instance.Credentials{}, invalidCredential()
}
username, usernameOK := secret.Data[usernameKey].(string)
password, passwordOK := secret.Data[passwordKey].(string)
if !usernameOK || !passwordOK || username == "" || password == "" {
return instance.Credentials{}, invalidCredential()
}
return instance.Credentials{Username: username, Password: password}, nil
}
func invalidCredential() error {
return instance.Failure{Reason: api.ReasonAuthenticationFailed, Operation: "administrative credential fields are missing"}
}
func classify(operation string, err error, login bool) error {
reason := api.ReasonDependencyUnavailable
var safe instance.Failure
if errors.As(err, &safe) {
return safe
}
var response *bao.ResponseError
if errors.As(err, &response) {
switch response.StatusCode {
case 403:
reason = api.ReasonInsufficientPrivileges
if login {
reason = api.ReasonAuthenticationFailed
}
case 400, 401:
reason = api.ReasonAuthenticationFailed
case 404:
reason = api.ReasonInvalidSpec
}
}
return instance.Failure{Reason: reason, Operation: operation}
}