Files
Ben Vincent cfbc06669e
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Initial vault-plugin-secrets-netbox
Vault/OpenBao secrets engine that mints NetBox API tokens via
/api/users/tokens/. A single seeded admin token (config) mints short-lived,
per-user tokens (roles -> creds) whose NetBox expiry is aligned to the Vault
lease; revoke deletes the token, renew extends its expiry. config/rotate
reissues the seeded admin token. Handles NetBox 4.6 v2 tokens (Bearer
nbt_<key>.<secret>) and legacy v1. Unit tests against an httptest NetBox mock;
dual Vault/OpenBao RPMs via nfpm; tag-driven release to artifactapi.

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
2026-08-08 22:14:59 +10:00

303 lines
10 KiB
Go

package netbox
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const configStoragePath = "config"
// netboxConfig is the connection to NetBox plus the seeded admin token the
// engine authenticates with.
type netboxConfig struct {
NetboxURL string `json:"netbox_url"`
// Token is the seeded admin credential (write-only from the API). For v2
// tokens this is the full "nbt_<key>.<secret>" string; for v1 the bare
// 40-char value.
Token string `json:"token"`
// AdminUserID / AdminTokenID identify the seeded token so config/rotate can
// mint a replacement for the same user and delete the old token. Optional for
// v2 (auto-discovered from the key); required for v1 rotation.
AdminUserID int `json:"admin_user_id"`
AdminTokenID int `json:"admin_token_id"`
// TokenVersion is the NetBox token version to request when minting (default
// 2). Set to 1 when the NetBox server has no API_TOKEN_PEPPERS configured.
TokenVersion int `json:"token_version"`
CACert string `json:"ca_cert"`
TLSSkipVerify bool `json:"tls_skip_verify"`
RequestTimeoutSeconds int `json:"request_timeout_seconds"`
}
func pathConfig(b *netboxBackend) *framework.Path {
return &framework.Path{
Pattern: "config",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "netbox",
OperationSuffix: "config",
},
Fields: map[string]*framework.FieldSchema{
"netbox_url": {
Type: framework.TypeString,
Description: "Base URL of the NetBox server, e.g. https://netbox.example.com.",
Required: true,
},
"token": {
Type: framework.TypeString,
Description: "Seeded NetBox admin API token used to mint per-user tokens. Write-only. v2: the full nbt_<key>.<secret> string; v1: the bare 40-char value.",
DisplayAttrs: &framework.DisplayAttributes{
Name: "Admin Token",
Sensitive: true,
},
},
"admin_user_id": {
Type: framework.TypeInt,
Description: "NetBox user id of the seeded admin token, so config/rotate can reissue it. Auto-discovered for v2 tokens if omitted.",
},
"admin_token_id": {
Type: framework.TypeInt,
Description: "NetBox token id of the seeded admin token, so config/rotate can delete it after reissue. Auto-discovered for v2 tokens if omitted.",
},
"token_version": {
Type: framework.TypeInt,
Description: "NetBox token version to request when minting (default 2). Use 1 if the NetBox server has no API_TOKEN_PEPPERS configured.",
Default: 2,
},
"ca_cert": {
Type: framework.TypeString,
Description: "PEM CA certificate that signed the NetBox server's TLS certificate.",
},
"tls_skip_verify": {
Type: framework.TypeBool,
Description: "Skip TLS verification of the NetBox server (not recommended).",
Default: false,
},
"request_timeout_seconds": {
Type: framework.TypeInt,
Description: "HTTP timeout in seconds for calls to NetBox (default 30).",
Default: 30,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathConfigRead},
logical.CreateOperation: &framework.PathOperation{Callback: b.pathConfigWrite},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigWrite},
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathConfigDelete},
},
ExistenceCheck: b.pathConfigExistenceCheck,
HelpSynopsis: "Configure the connection to NetBox and the seeded admin token.",
HelpDescription: "Configure the URL, TLS settings, and seeded admin token the backend uses to mint NetBox tokens. Roles are configured on roles/<name>.",
}
}
func pathConfigRotate(b *netboxBackend) *framework.Path {
return &framework.Path{
Pattern: "config/rotate$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "netbox",
OperationSuffix: "config-rotate",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigRotate},
},
HelpSynopsis: "Reissue the seeded NetBox admin token.",
HelpDescription: "Mints a fresh admin token for the seeded user with the current token, stores it, and deletes the old token. NetBox has no in-place rotation.",
}
}
func (b *netboxBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return false, err
}
return config != nil, nil
}
func (b *netboxBackend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
// The admin token is never returned.
return &logical.Response{
Data: map[string]interface{}{
"netbox_url": config.NetboxURL,
"admin_user_id": config.AdminUserID,
"admin_token_id": config.AdminTokenID,
"token_version": config.TokenVersion,
"tls_skip_verify": config.TLSSkipVerify,
"request_timeout_seconds": config.RequestTimeoutSeconds,
},
}, nil
}
func (b *netboxBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
if req.Operation == logical.UpdateOperation {
return nil, errors.New("config not found during update operation")
}
config = &netboxConfig{}
}
if v, ok := data.GetOk("netbox_url"); ok {
config.NetboxURL = v.(string)
}
if v, ok := data.GetOk("token"); ok {
config.Token = v.(string)
}
if v, ok := data.GetOk("admin_user_id"); ok {
config.AdminUserID = v.(int)
}
if v, ok := data.GetOk("admin_token_id"); ok {
config.AdminTokenID = v.(int)
}
if v, ok := data.GetOk("token_version"); ok {
config.TokenVersion = v.(int)
} else if req.Operation == logical.CreateOperation {
config.TokenVersion = data.Get("token_version").(int)
}
if v, ok := data.GetOk("ca_cert"); ok {
config.CACert = v.(string)
}
if v, ok := data.GetOk("tls_skip_verify"); ok {
config.TLSSkipVerify = v.(bool)
}
if v, ok := data.GetOk("request_timeout_seconds"); ok {
config.RequestTimeoutSeconds = v.(int)
} else if req.Operation == logical.CreateOperation {
config.RequestTimeoutSeconds = data.Get("request_timeout_seconds").(int)
}
if config.NetboxURL == "" {
return logical.ErrorResponse("netbox_url is required"), nil
}
if config.Token == "" {
return logical.ErrorResponse("token (admin) is required"), nil
}
if config.TokenVersion != 1 && config.TokenVersion != 2 {
return logical.ErrorResponse("token_version must be 1 or 2"), nil
}
return nil, setJSON(ctx, req.Storage, configStoragePath, config)
}
func (b *netboxBackend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
return nil, req.Storage.Delete(ctx, configStoragePath)
}
func (b *netboxBackend) pathConfigRotate(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
b.lock.Lock()
defer b.lock.Unlock()
config, err := getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if config == nil {
return nil, errBackendNotConfigured
}
client, err := newClient(config)
if err != nil {
return nil, err
}
// Resolve the ids of the current admin token so we can reissue for the same
// user and delete the old one. Auto-discover from the v2 key if not stored.
userID, oldTokenID := config.AdminUserID, config.AdminTokenID
if (userID == 0 || oldTokenID == 0) && strings.HasPrefix(config.Token, tokenPrefix) {
if key := adminTokenKey(config.Token); key != "" {
if id, uid, lerr := client.LookupTokenByKey(ctx, key); lerr == nil {
if oldTokenID == 0 {
oldTokenID = id
}
if userID == 0 {
userID = uid
}
}
}
}
if userID == 0 {
return logical.ErrorResponse("admin_user_id is unknown; set it on config to enable rotation"), nil
}
minted, err := client.MintToken(ctx, mintRequest{
UserID: userID,
WriteEnabled: true,
Description: "vault-managed netbox admin token",
})
if err != nil {
return nil, fmt.Errorf("minting replacement admin token: %w", err)
}
config.Token = credentialFor(minted)
config.AdminUserID = userID
config.AdminTokenID = minted.ID
if err := setJSON(ctx, req.Storage, configStoragePath, config); err != nil {
return nil, fmt.Errorf("persisting rotated admin token: %w", err)
}
// Delete the superseded token using the new credential.
if oldTokenID != 0 && oldTokenID != minted.ID {
if newClient, cerr := newClient(config); cerr == nil {
if derr := newClient.DeleteToken(ctx, oldTokenID); derr != nil {
b.Logger().Warn("netbox: could not delete superseded admin token", "token_id", oldTokenID, "error", derr)
}
}
}
return &logical.Response{
Data: map[string]interface{}{
"admin_user_id": config.AdminUserID,
"admin_token_id": config.AdminTokenID,
"rotated_at": time.Now().UTC().Format(time.RFC3339),
},
}, nil
}
// adminTokenKey extracts the v2 identification key from an "nbt_<key>.<secret>"
// credential, or "" if the shape is not recognised.
func adminTokenKey(credential string) string {
rest := strings.TrimPrefix(credential, tokenPrefix)
if key, _, ok := strings.Cut(rest, "."); ok {
return key
}
return ""
}
func getConfig(ctx context.Context, s logical.Storage) (*netboxConfig, error) {
entry, err := s.Get(ctx, configStoragePath)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
config := &netboxConfig{}
if err := entry.DecodeJSON(config); err != nil {
return nil, err
}
return config, nil
}
// setJSON stores a value as a JSON storage entry.
func setJSON(ctx context.Context, s logical.Storage, key string, value interface{}) error {
entry, err := logical.StorageEntryJSON(key, value)
if err != nil {
return err
}
return s.Put(ctx, entry)
}