cfbc06669e
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
303 lines
10 KiB
Go
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)
|
|
}
|