Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ build-plugin:

.PHONY:load-resources
load-resources:
cd test/terraform && terraform init
cd test/terraform && terraform init -upgrade
cd test/terraform && terraform apply -auto-approve

.PHONY:export-resources
Expand Down
1 change: 1 addition & 0 deletions cmd/policy-gate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func Backend(c *logical.BackendConfig) *pgate.Backend {
BackendType: logical.TypeLogical,
Help: "[PolicyGate] Vault/OpenBao Plugin for conditional access to Policies",
RunningVersion: Version,
Clean: bFinal.CleanupVaultClient,
Paths: []*framework.Path{
// Provided by Base package
base.PathConfig(&baseBackend),
Expand Down
3 changes: 0 additions & 3 deletions internal/base/crud.config.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ func StoreConfigurationToStorage[T PluginConfig](ctx context.Context, b *BaseBac
if err != nil {
b.Logger().Error("[-] Could not marshal configuration to JSON",
"path", path,
"config", config,
"error", err,
)
return err
Expand All @@ -69,8 +68,6 @@ func StoreConfigurationToStorage[T PluginConfig](ctx context.Context, b *BaseBac
if err != nil {
b.Logger().Error("[-] Could not store configuration to storage",
"path", path,
"config", config,
"configJSON", configJSON,
"error", err,
)
return err
Expand Down
39 changes: 18 additions & 21 deletions internal/clients/config/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,31 @@

package config

import (
"fmt"
)
import "fmt"

type ConfigApiVaultAppRole struct {
Url string `json:"url"`
RoleID string `json:"role_id"`
RoleSecret string `json:"role_secret"`
AppRoleMount string `json:"approle_mount"`
type ConfigApiVaultPeriodicToken struct {
Url string `json:"url"`
Token string `json:"token"`
}

func NewConfigApiVaultAppRole() ConfigApiVaultAppRole {
return ConfigApiVaultAppRole{
Url: "https://localhost:8200",
AppRoleMount: "approle",
}
func NewConfigApiVaultPeriodicToken() ConfigApiVaultPeriodicToken {
return ConfigApiVaultPeriodicToken{Url: "https://localhost:8200"}
}

func (c *ConfigApiVaultAppRole) SetConfigurationKey(key string, value interface{}) error {
func (c *ConfigApiVaultPeriodicToken) SetConfigurationKey(key string, value interface{}) error {
switch key {
case "url":
c.Url = value.(string)
case "role_id":
c.RoleID = value.(string)
case "role_secret":
c.RoleSecret = value.(string)
case "approle_mount":
c.AppRoleMount = value.(string)
url, ok := value.(string)
if !ok {
return fmt.Errorf("invalid type for 'url', expected string")
}
c.Url = url
case "token":
token, ok := value.(string)
if !ok {
return fmt.Errorf("invalid type for 'token', expected string")
}
c.Token = token
default:
return fmt.Errorf("unknown configuration key: %s", key)
}
Expand Down
200 changes: 128 additions & 72 deletions internal/clients/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,120 +14,176 @@ import (
"context"
"fmt"
"net/http"
"strings"
"time"

"github.com/hashicorp/go-secure-stdlib/parseutil"
"github.com/hashicorp/vault/api"

clientConfig "github.com/gateplane-io/vault-plugins/internal/clients/config"
)

func NewVaultAppRoleClient(ctx context.Context, cfg clientConfig.ConfigApiVaultAppRole, httpClient *http.Client) (*api.Client, error) {
if cfg.Url == "" {
type PeriodicTokenInfo struct {
Secret *api.Secret
Accessor string
PeriodSeconds int
}

func NewVaultClient(url string, httpClient *http.Client) (*api.Client, error) {
if strings.TrimSpace(url) == "" {
return nil, fmt.Errorf("vault URL is required")
}

// create Vault config
vaultCfg := api.DefaultConfig()
vaultCfg.Address = cfg.Url
vaultConfig := api.DefaultConfig()
vaultConfig.Address = url
if httpClient != nil {
vaultCfg.HttpClient = httpClient
}
// reduce long blocking by setting a reasonable timeout if none set on httpClient
if vaultCfg.HttpClient == nil {
vaultCfg.HttpClient = &http.Client{Timeout: 10 * time.Second}
vaultConfig.HttpClient = httpClient
} else if vaultConfig.HttpClient == nil {
vaultConfig.HttpClient = &http.Client{Timeout: 10 * time.Second}
}

client, err := api.NewClient(vaultCfg)
client, err := api.NewClient(vaultConfig)
if err != nil {
return nil, fmt.Errorf("creating vault client: %w", err)
}

token, err := LoginWithAppRole(ctx, client, cfg.RoleID, cfg.RoleSecret, cfg.AppRoleMount)
if err != nil {
return nil, fmt.Errorf("approle login failed: %w", err)
}

// set token on client
client.SetToken(token)
return client, nil
}

func LoginWithAppRole(ctx context.Context, client *api.Client, roleID, secretID, mount string) (string, error) {
if client == nil {
return "", fmt.Errorf("vault client is nil")
func NewVaultWrappedTokenClient(ctx context.Context, url, wrappedToken string, httpClient *http.Client) (*api.Client, *PeriodicTokenInfo, error) {
if strings.TrimSpace(wrappedToken) == "" {
return nil, nil, fmt.Errorf("wrapped token is required")
}

client, err := NewVaultClient(url, httpClient)
if err != nil {
return nil, nil, err
}
if roleID == "" {
return "", fmt.Errorf("roleID is required")

secret, err := client.Logical().UnwrapWithContext(ctx, wrappedToken)
client.ClearToken()
if err != nil {
return nil, nil, tokenSafeError("unwrapping token", err, wrappedToken)
}
if secretID == "" {
return "", fmt.Errorf("secretID is required")

token, err := secret.TokenID()
if err != nil {
return nil, nil, tokenSafeError("reading unwrapped token", err, wrappedToken)
}
if mount == "" {
mount = "approle"
if strings.TrimSpace(token) == "" {
return nil, nil, fmt.Errorf("wrapped response did not contain a token")
}

// Normalize mount: remove leading "auth/" if provided by caller
if len(mount) >= 5 && mount[:5] == "auth/" {
mount = mount[5:]
client.SetToken(token)
info, err := ValidatePeriodicOrphanToken(ctx, client)
if err != nil {
client.ClearToken()
return nil, nil, tokenSafeError("validating unwrapped token", err, wrappedToken, token)
}
return client, info, nil
}

payload := map[string]interface{}{
"role_id": roleID,
"secret_id": secretID,
func NewVaultPeriodicTokenClient(ctx context.Context, url, token string, httpClient *http.Client) (*api.Client, *PeriodicTokenInfo, error) {
if strings.TrimSpace(token) == "" {
return nil, nil, fmt.Errorf("vault token is required")
}

path := fmt.Sprintf("auth/%s/login", mount)
secret, err := client.Logical().WriteWithContext(ctx, path, payload)
client, err := NewVaultClient(url, httpClient)
if err != nil {
return "", fmt.Errorf("approle login failed: %w", err)
}
if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" {
return "", fmt.Errorf("approle login returned no token")
return nil, nil, err
}
client.SetToken(token)

client.SetToken(secret.Auth.ClientToken)
return secret.Auth.ClientToken, nil
info, err := ValidatePeriodicOrphanToken(ctx, client)
if err != nil {
client.ClearToken()
return nil, nil, tokenSafeError("validating configured token", err, token)
}
return client, info, nil
}

// IsAuthenticated checks whether the provided Vault client currently has a valid token.
// It returns true if the client has a non-empty token and the token lookup succeeds.
func IsAuthenticatedVault(ctx context.Context, client *api.Client) (bool, error) {
func ValidatePeriodicOrphanToken(ctx context.Context, client *api.Client) (*PeriodicTokenInfo, error) {
if client == nil {
return false, fmt.Errorf("vault client is nil")
return nil, fmt.Errorf("vault client is nil")
}
token := client.Token()
if token == "" {
return false, nil
if client.Token() == "" {
return nil, fmt.Errorf("vault token is required")
}

// token/lookup-self works even for accessor-based checks; use ReadWithContext for context support
secret, _ := client.Logical().ReadWithContext(ctx, "auth/token/lookup-self")
if secret == nil || secret.Data == nil {
return false, nil
lookup, err := client.Auth().Token().LookupSelfWithContext(ctx)
if err != nil {
return nil, fmt.Errorf("looking up token: %w", err)
}
if lookup == nil || lookup.Data == nil {
return nil, fmt.Errorf("token lookup returned no data")
}

// Optionally ensure token is not expired/renewable info present; presence of data means valid
return true, nil
}
orphan, err := parseutil.ParseBool(lookup.Data["orphan"])
if err != nil {
return nil, fmt.Errorf("parsing token orphan status: %w", err)
}
if !orphan {
return nil, fmt.Errorf("token must be orphan")
}

// EnsureAuthentication checks if the client is authenticated and, if not, logs in using AppRole.
// - client must be preconfigured (address, http client, TLS).
// - roleID and secretID are used for AppRole login when needed.
// - mount is the approle mount path (e.g., "approle"); empty defaults to "approle".
// Returns an error on failure.
func EnsureAuthenticationVault(ctx context.Context, client *api.Client, roleID, secretID, mount string) error {
if client == nil {
return fmt.Errorf("vault client is nil")
renewable, err := lookup.TokenIsRenewable()
if err != nil {
return nil, fmt.Errorf("parsing token renewable status: %w", err)
}
if !renewable {
return nil, fmt.Errorf("token must be renewable")
}

ok, _ := IsAuthenticatedVault(ctx, client)
if ok {
return nil
ttl, err := lookup.TokenTTL()
if err != nil {
return nil, fmt.Errorf("parsing token TTL: %w", err)
}
if ttl <= 0 {
return nil, fmt.Errorf("token TTL must be greater than zero")
}

period, err := parseutil.ParseDurationSecond(lookup.Data["period"])
if err != nil {
return nil, fmt.Errorf("parsing token period: %w", err)
}
if period <= 0 {
return nil, fmt.Errorf("token must be periodic")
}

if rawExplicitMaxTTL, ok := lookup.Data["explicit_max_ttl"]; ok && rawExplicitMaxTTL != nil {
explicitMaxTTL, err := parseutil.ParseDurationSecond(rawExplicitMaxTTL)
if err != nil {
return nil, fmt.Errorf("parsing token explicit max TTL: %w", err)
}
if explicitMaxTTL > 0 {
return nil, fmt.Errorf("token must not have an explicit max TTL")
}
}

_, err := LoginWithAppRole(ctx, client, roleID, secretID, mount)
accessor, err := lookup.TokenAccessor()
if err != nil {
return fmt.Errorf("approle login failed: %w", err)
return nil, fmt.Errorf("parsing token accessor: %w", err)
}
if strings.TrimSpace(accessor) == "" {
return nil, fmt.Errorf("token lookup returned no accessor")
}

periodSeconds := int(period / time.Second)
return &PeriodicTokenInfo{
Accessor: accessor,
PeriodSeconds: periodSeconds,
Secret: &api.Secret{Auth: &api.SecretAuth{
ClientToken: client.Token(),
Orphan: true,
Renewable: true,
LeaseDuration: int(ttl / time.Second),
}},
}, nil
}

func tokenSafeError(operation string, err error, tokens ...string) error {
message := err.Error()
for _, token := range tokens {
if token != "" {
message = strings.ReplaceAll(message, token, "[redacted]")
}
}
return nil
return fmt.Errorf("%s: %s", operation, message)
}
Loading
Loading