c46641dafb
Vault/OpenBao secrets engine managing Rancher API tokens via the public tokens.ext.cattle.io API. - config: Rancher connection (URL + TLS) - service-accounts/<name>: seeded root tokens, auto-rotated before Rancher's TTL cap via a PeriodicFunc (default 45d rotation, 90d token TTL); the current token mints its own replacement. Manual /rotate endpoint too. - roles/<name>: mint policy referencing a service account; cluster_name + TTL scoping (Rancher tokens inherit the seeding user's RBAC). - creds/<role>: dynamic, lease-bound tokens deleted from Rancher on revoke. Ports the bind-tsig Woodpecker RPM release, nfpm packaging, and a mock-Rancher e2e (Vault + OpenBao). Unit tests cover the full lifecycle.
174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
// Package rancher implements a Vault / OpenBao secrets engine that manages
|
|
// Rancher API tokens through the public tokens.ext.cattle.io API.
|
|
//
|
|
// The engine is seeded with a long-lived Rancher admin (or service-account)
|
|
// token. Because Rancher caps token TTLs (commonly 90 days), the engine rotates
|
|
// each seeded token well before expiry: on a schedule it uses the current token
|
|
// to mint a fresh one for the same user and swaps it in, so the credential never
|
|
// lapses. Roles then mint short-lived, cluster-scoped tokens on demand, each
|
|
// bound to a Vault lease and deleted from Rancher on revocation.
|
|
//
|
|
// Rancher only lets a token be created for the caller's own user, so a minted
|
|
// token inherits the seeding user's RBAC. To get least-privilege tokens, seed a
|
|
// separate service-account per privilege level and point roles at it.
|
|
package rancher
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
)
|
|
|
|
// errBackendNotConfigured is returned when a credential is requested before the
|
|
// Rancher connection has been configured.
|
|
var errBackendNotConfigured = errors.New("rancher backend not configured; write config first")
|
|
|
|
type rancherBackend struct {
|
|
*framework.Backend
|
|
|
|
lock sync.RWMutex
|
|
}
|
|
|
|
// Factory returns a configured Rancher secrets backend.
|
|
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
|
|
b := backend()
|
|
if err := b.Setup(ctx, conf); err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func backend() *rancherBackend {
|
|
b := &rancherBackend{}
|
|
|
|
b.Backend = &framework.Backend{
|
|
Help: strings.TrimSpace(backendHelp),
|
|
BackendType: logical.TypeLogical,
|
|
PathsSpecial: &logical.Paths{
|
|
SealWrapStorage: []string{configStoragePath},
|
|
},
|
|
Paths: framework.PathAppend(
|
|
[]*framework.Path{
|
|
pathConfig(b),
|
|
pathServiceAccount(b),
|
|
pathServiceAccountsList(b),
|
|
pathServiceAccountRotate(b),
|
|
pathRole(b),
|
|
pathRolesList(b),
|
|
pathCredentials(b),
|
|
},
|
|
),
|
|
Secrets: []*framework.Secret{
|
|
b.rancherTokenSecret(),
|
|
},
|
|
// PeriodicFunc runs on the active node roughly once a minute; it drives
|
|
// the automatic root-token rotation.
|
|
PeriodicFunc: b.periodicRotate,
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
// clientFor builds a Rancher client from the global config, authenticated with
|
|
// the given bearer token (a service account's current token).
|
|
func (b *rancherBackend) clientFor(ctx context.Context, s logical.Storage, bearer string) (*rancherClient, error) {
|
|
config, err := getConfig(ctx, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if config == nil {
|
|
return nil, errBackendNotConfigured
|
|
}
|
|
return newClient(config, bearer)
|
|
}
|
|
|
|
// periodicRotate rotates every service-account token whose age has reached its
|
|
// rotation_period. It never blocks the engine: individual failures are logged
|
|
// and retried on the next tick.
|
|
func (b *rancherBackend) periodicRotate(ctx context.Context, req *logical.Request) error {
|
|
b.lock.Lock()
|
|
defer b.lock.Unlock()
|
|
|
|
names, err := req.Storage.List(ctx, serviceAccountStoragePrefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC()
|
|
for _, name := range names {
|
|
sa, err := b.getServiceAccount(ctx, req.Storage, name)
|
|
if err != nil || sa == nil {
|
|
continue
|
|
}
|
|
if !sa.due(now) {
|
|
continue
|
|
}
|
|
if err := b.rotateServiceAccount(ctx, req.Storage, name, sa); err != nil {
|
|
b.Logger().Error("rancher: service-account rotation failed", "service_account", name, "error", err)
|
|
continue
|
|
}
|
|
b.Logger().Info("rancher: rotated service-account token", "service_account", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rotateServiceAccount mints a fresh token for the service-account user with the
|
|
// current token, persists it, then best-effort deletes the previous token. The
|
|
// caller holds b.lock.
|
|
func (b *rancherBackend) rotateServiceAccount(ctx context.Context, s logical.Storage, name string, sa *serviceAccount) error {
|
|
if sa.Token == "" {
|
|
return fmt.Errorf("service account %q has no token to rotate with", name)
|
|
}
|
|
client, err := b.clientFor(ctx, s, sa.Token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
value, tokenName, err := client.MintToken(ctx, mintRequest{
|
|
GenerateName: fmt.Sprintf("vault-%s-", name),
|
|
Description: fmt.Sprintf("vault-managed root token for service-account %q", name),
|
|
TTL: sa.TokenTTL,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("minting replacement token: %w", err)
|
|
}
|
|
|
|
old := sa.TokenName
|
|
sa.Token = value
|
|
sa.TokenName = tokenName
|
|
sa.LastRotated = time.Now().UTC()
|
|
if err := setJSON(ctx, s, serviceAccountStoragePrefix+name, sa); err != nil {
|
|
// The new token is live but unstored; deleting it keeps Rancher tidy and
|
|
// leaves the old token (still stored) in charge for the next tick.
|
|
_ = client.DeleteToken(ctx, tokenName)
|
|
return fmt.Errorf("persisting rotated token: %w", err)
|
|
}
|
|
|
|
// Retire the previous token. The new token authenticates the delete.
|
|
if old != "" && old != tokenName {
|
|
newClient, cerr := b.clientFor(ctx, s, value)
|
|
if cerr == nil {
|
|
if derr := newClient.DeleteToken(ctx, old); derr != nil {
|
|
b.Logger().Warn("rancher: could not delete superseded token", "service_account", name, "token", old, "error", derr)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const backendHelp = `
|
|
The rancher secrets engine manages Rancher API tokens via the public
|
|
tokens.ext.cattle.io API.
|
|
|
|
Seed a service account with a long-lived Rancher token; the engine rotates it
|
|
before Rancher's TTL cap expires so it never lapses. Roles mint short-lived,
|
|
optionally cluster-scoped tokens bound to a Vault lease and deleted on
|
|
revocation. A minted token inherits the seeding user's RBAC, so seed one service
|
|
account per privilege level for least-privilege scoping.
|
|
`
|