Initial vault-plugin-secrets-rancher scaffold
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.
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
||||
// 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.
|
||||
`
|
||||
Reference in New Issue
Block a user