From c9be7517d43884454ac2911847d1b8aa90b41a5a Mon Sep 17 00:00:00 2001 From: John Torakis Date: Thu, 23 Jul 2026 11:52:36 +0300 Subject: [PATCH 1/4] test(policygate): update to wrapped_token authentication --- cmd/policy-gate/main.go | 1 + internal/base/crud.config.go | 3 --- test/terraform/infra.tf | 14 +++++++------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/policy-gate/main.go b/cmd/policy-gate/main.go index aed607f..9785037 100644 --- a/cmd/policy-gate/main.go +++ b/cmd/policy-gate/main.go @@ -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), diff --git a/internal/base/crud.config.go b/internal/base/crud.config.go index 391968c..9c163d7 100644 --- a/internal/base/crud.config.go +++ b/internal/base/crud.config.go @@ -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 @@ -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 diff --git a/test/terraform/infra.tf b/test/terraform/infra.tf index ee213a2..16776d8 100644 --- a/test/terraform/infra.tf +++ b/test/terraform/infra.tf @@ -1,5 +1,5 @@ module "infra" { - source = "github.com/gateplane-io/terraform-gateplane-setup?ref=0.4.0" + source = "github.com/gateplane-io/terraform-gateplane-setup?ref=0.5.0" # source = "./../../../terraform-gateplane-setup" // To showcase the WebUI locally @@ -19,10 +19,10 @@ module "infra" { } policy_gate_plugin = { - filename = "gateplane-policy-gate" - version = var.plugin_test_version - sha256 = filesha256("${path.module}/../../dist/policy-gate_linux_amd64_v1/gateplane-policy-gate") - approle_policy = "gateplane-policy-gate-policy" + filename = "gateplane-policy-gate" + version = var.plugin_test_version + sha256 = filesha256("${path.module}/../../dist/policy-gate_linux_amd64_v1/gateplane-policy-gate") + policy_name = "gateplane-policy-gate-policy" } } @@ -30,7 +30,7 @@ module "infra" { // used by the tests module "mock" { depends_on = [module.infra] - source = "github.com/gateplane-io/terraform-test-modules.git//gateplane-mock?ref=1.2.0" + source = "github.com/gateplane-io/terraform-test-modules.git//gateplane-mock?ref=1.3.0" # source = "./../../../terraform-test-modules/gateplane-mock" name = "mock" @@ -42,7 +42,7 @@ module "mock" { module "access" { depends_on = [module.infra] - source = "github.com/gateplane-io/terraform-gateplane-policy-gate?ref=1.2.0" + source = "github.com/gateplane-io/terraform-gateplane-policy-gate?ref=1.3.0" # source = "./../../../terraform-gateplane-policy-gate" name = "pgate" From 42a3dc3922abe827815a4061d3ba041642927795 Mon Sep 17 00:00:00 2001 From: John Torakis Date: Thu, 23 Jul 2026 12:23:23 +0300 Subject: [PATCH 2/4] feat(policygate): remove `approle` auth in favour of wrapped renewable token --- internal/clients/config/vault.go | 39 ++-- internal/clients/vault.go | 190 +++++++++++------- .../policy-gate/endpoint.config.api.vault.go | 104 ++++------ internal/policy-gate/plugin.go | 186 ++++++++++++----- pkg/responses/config.api.vault.go | 7 +- 5 files changed, 315 insertions(+), 211 deletions(-) diff --git a/internal/clients/config/vault.go b/internal/clients/config/vault.go index 9795bd1..a802d72 100644 --- a/internal/clients/config/vault.go +++ b/internal/clients/config/vault.go @@ -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) } diff --git a/internal/clients/vault.go b/internal/clients/vault.go index 1f32e8b..c6154d4 100644 --- a/internal/clients/vault.go +++ b/internal/clients/vault.go @@ -14,120 +14,166 @@ 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 + 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") } - if roleID == "" { - return "", fmt.Errorf("roleID is required") + + client, err := NewVaultClient(url, httpClient) + if err != nil { + return nil, nil, err } - if secretID == "" { - return "", fmt.Errorf("secretID is required") + + secret, err := client.Logical().UnwrapWithContext(ctx, wrappedToken) + client.ClearToken() + if err != nil { + return nil, nil, tokenSafeError("unwrapping token", err, wrappedToken) } - if mount == "" { - mount = "approle" + + token, err := secret.TokenID() + if err != nil { + return nil, nil, tokenSafeError("reading unwrapped token", err, wrappedToken) + } + 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") } - _, err := LoginWithAppRole(ctx, client, roleID, secretID, mount) + period, err := parseutil.ParseDurationSecond(lookup.Data["period"]) if err != nil { - return fmt.Errorf("approle login failed: %w", err) + 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") + } + } + + periodSeconds := int(period / time.Second) + return &PeriodicTokenInfo{ + 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) } diff --git a/internal/policy-gate/endpoint.config.api.vault.go b/internal/policy-gate/endpoint.config.api.vault.go index f671b8f..9e3cc1a 100644 --- a/internal/policy-gate/endpoint.config.api.vault.go +++ b/internal/policy-gate/endpoint.config.api.vault.go @@ -18,13 +18,11 @@ import ( "github.com/hashicorp/vault/sdk/logical" "github.com/gateplane-io/vault-plugins/internal/base" - clients "github.com/gateplane-io/vault-plugins/internal/clients" + "github.com/gateplane-io/vault-plugins/internal/clients" clientConfig "github.com/gateplane-io/vault-plugins/internal/clients/config" - "github.com/gateplane-io/vault-plugins/pkg/responses" ) -// Path for plugin configuration func PathConfigApiVault(b *Backend) *framework.Path { return &framework.Path{ Pattern: ConfigAPIVaultKey, @@ -32,107 +30,93 @@ func PathConfigApiVault(b *Backend) *framework.Path { "url": { Type: framework.TypeString, Description: "The URL of the Vault/OpenBao instance", - Required: true, - }, - "role_id": { - Type: framework.TypeString, - Description: "The AppRole ID to authenticate against", - Required: true, - }, - "role_secret": { - Type: framework.TypeString, - Description: "The AppRole Secret to authenticate with", Required: false, }, - "approle_mount": { + "wrapped_token": { Type: framework.TypeString, - Description: "The Vault Auth Mount for AppRole", - Required: false, + Description: "A single-use response-wrapped periodic orphan token", + Required: true, }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ logical.UpdateOperation: b.handleConfigApiVaultUpdate, logical.ReadOperation: b.handleConfigApiVaultRead, }, - HelpSynopsis: "Configures access and authentication to Vault/OpenBao API", - HelpDescription: `This endpoint sets the Vault/OpenBao endpoints and AppRole configuration - needed to assign and remove Policies to Vault/OpenBao Entities. - - 'url' configures the Vault/OpenBao network endpoint to be used to contact the API + HelpDescription: `This endpoint configures the Vault/OpenBao endpoint and the token used + to assign and remove policies from Vault/OpenBao entities. - 'role_id' and 'role_secret' configure the AppRole credentials to authenticate to and interact with the API + 'url' configures the Vault/OpenBao network endpoint. When omitted, the existing URL is retained. - 'approle_mount' configures the mountpoint of the AppRole Auth Method, where the AppRole should authenticate against. + 'wrapped_token' must contain a single-use response-wrapped renewable periodic orphan token. + The wrapping token is consumed once and is never stored. Only the unwrapped token is persisted + in the plugin's encrypted storage and renewed through 'auth/token/renew-self'. - The provided AppRole must be able to - 'read' and 'update' the 'identity/entity/id/*', - and 'read' the 'auth/token/lookup-self' paths. - `, + The token must be able to 'read' and 'update' 'identity/entity/id/*', 'read' + 'auth/token/lookup-self', and 'update' 'auth/token/renew-self'.`, } } func (b *Backend) handleConfigApiVaultUpdate(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - entityID := req.EntityID b.BaseMutex.Lock() defer b.BaseMutex.Unlock() - config, err := base.GetConfiguration[*clientConfig.ConfigApiVaultAppRole](ctx, b.BaseBackend, req, "") + config, err := base.GetConfiguration[*clientConfig.ConfigApiVaultPeriodicToken](ctx, b.BaseBackend, req, "") if err != nil { return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrMissingRequiredState } - for key := range d.Raw { - value, ok := d.GetOk(key) + if rawURL, ok := d.GetOk("url"); ok { + url, ok := rawURL.(string) if !ok { - continue - } - b.Logger().Info("[*] Replacing configuration value", - "EntityID", entityID, - "ConfigKey", key, - // "OldValue", config - "NewValue", value, - ) - - err := config.SetConfigurationKey(key, value) - if err != nil { - return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrPermissionDenied + return logical.ErrorResponse("invalid type for 'url', expected string"), logical.ErrInvalidRequest } + config.Url = url } - err = base.StoreConfiguration[*clientConfig.ConfigApiVaultAppRole](ctx, b.BaseBackend, req, config, "") + rawWrappedToken, ok := d.GetOk("wrapped_token") + if !ok { + return logical.ErrorResponse("wrapped_token is required"), logical.ErrInvalidRequest + } + wrappedToken, ok := rawWrappedToken.(string) + if !ok || wrappedToken == "" { + return logical.ErrorResponse("wrapped_token is required"), logical.ErrInvalidRequest + } + + vaultClient, tokenInfo, err := clients.NewVaultWrappedTokenClient(ctx, config.Url, wrappedToken, nil) if err != nil { + return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrInvalidRequest + } + config.Token = vaultClient.Token() + + if err := base.StoreConfiguration[*clientConfig.ConfigApiVaultPeriodicToken](ctx, b.BaseBackend, req, config, ""); err != nil { + _ = vaultClient.Auth().Token().RevokeSelfWithContext(ctx, config.Token) + vaultClient.ClearToken() return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrMissingRequiredState } - vaultClient, err := clients.NewVaultAppRoleClient(ctx, *config, nil) - if err != nil { - return &logical.Response{Warnings: []string{ - fmt.Sprintf("%s", err), - }}, nil + if err := b.ReplaceVaultClient(vaultClient, tokenInfo); err != nil { + return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrMissingRequiredState } - b.VaultClient = vaultClient return &logical.Response{}, nil } -func (b *Backend) handleConfigApiVaultRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { +func (b *Backend) handleConfigApiVaultRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { b.BaseMutex.Lock() defer b.BaseMutex.Unlock() - config, err := base.GetConfiguration[*clientConfig.ConfigApiVaultAppRole](ctx, b.BaseBackend, req, "") + config, err := base.GetConfiguration[*clientConfig.ConfigApiVaultPeriodicToken](ctx, b.BaseBackend, req, "") if err != nil { return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrMissingRequiredState } - responseObj := responses.ConfigApiVaultResponse{ - Url: config.Url, - RoleID: config.RoleID, - AppRoleMount: config.AppRoleMount, - RoleSecretSet: config.RoleSecret != "", - } - - responseData, err := base.StructToMap(responseObj) + tokenSet, tokenPeriod := b.VaultTokenStatus() + responseData, err := base.StructToMap(responses.ConfigApiVaultResponse{ + Url: config.Url, + TokenSet: tokenSet || config.Token != "", + TokenPeriod: tokenPeriod, + }) if err != nil { return logical.ErrorResponse(fmt.Sprint(err)), nil } diff --git a/internal/policy-gate/plugin.go b/internal/policy-gate/plugin.go index 3cdadcf..4780b4d 100644 --- a/internal/policy-gate/plugin.go +++ b/internal/policy-gate/plugin.go @@ -12,47 +12,46 @@ package policy_gate import ( "context" + "fmt" "sync" - "github.com/hashicorp/vault/sdk/logical" - vault "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/logical" "github.com/gateplane-io/vault-plugins/internal/base" - clients "github.com/gateplane-io/vault-plugins/internal/clients" + "github.com/gateplane-io/vault-plugins/internal/clients" clientConfig "github.com/gateplane-io/vault-plugins/internal/clients/config" "github.com/gateplane-io/vault-plugins/internal/utils" ) -/* Storage Keys */ const ConfigAPIVaultKey = "config/api/vault" const ConfigAccessKey = "config/access" type Backend struct { *base.BaseBackend - Mutex sync.Mutex - VaultClient *vault.Client + Mutex sync.Mutex + VaultClient *vault.Client + vaultTokenWatcher *vault.LifetimeWatcher + vaultTokenPeriod int } func (b *Backend) Initialize(ctx context.Context, req *logical.InitializationRequest) error { - b.Mutex.Lock() - defer b.Mutex.Unlock() b.Logger().Info("Initializing plugin configuration") - // Initialize the base - err := b.BaseBackend.Initialize(ctx, req) - if err != nil { + if err := b.BaseBackend.Initialize(ctx, req); err != nil { b.Logger().Error("Could not initialize Plugin Base") return err } configAccess := NewConfigAccess() - _, err = base.StoreConfigurationToStorageIfNotPresent[*ConfigAccess](ctx, + if _, err := base.StoreConfigurationToStorageIfNotPresent[*ConfigAccess](ctx, b.BaseBackend, req.Storage, &configAccess, ConfigAccessKey, - ) + ); err != nil { + return err + } - config := clientConfig.NewConfigApiVaultAppRole() - exists, err := base.StoreConfigurationToStorageIfNotPresent[*clientConfig.ConfigApiVaultAppRole](ctx, + config := clientConfig.NewConfigApiVaultPeriodicToken() + exists, err := base.StoreConfigurationToStorageIfNotPresent[*clientConfig.ConfigApiVaultPeriodicToken](ctx, b.BaseBackend, req.Storage, &config, ConfigAPIVaultKey, ) if err != nil { @@ -60,23 +59,32 @@ func (b *Backend) Initialize(ctx context.Context, req *logical.InitializationReq return err } if exists { - err = b.EnsureVaultAPI(ctx, req.Storage) + storedConfig, err := base.GetConfigurationFromStorage[*clientConfig.ConfigApiVaultPeriodicToken](ctx, + b.BaseBackend, req.Storage, ConfigAPIVaultKey, + ) if err != nil { - b.Logger().Error("[-] Could not initialize Vault API with existing Configuration", - "path", ConfigAPIVaultKey, - "AppRoleID", config.RoleID, - "AppRoleMount", config.AppRoleMount, - ) return err } + if storedConfig.Token != "" { + if err := b.EnsureVaultAPI(ctx, req.Storage); err != nil { + b.Logger().Error("[-] Could not initialize Vault API with existing configuration", + "path", ConfigAPIVaultKey, + "error", err, + ) + return err + } + } } b.BaseBackend.ClaimArray = utils.NewCallbackArray( - (func(ctx context.Context, requ *logical.Request, ownerID string) (map[string]interface{}, error) { // Append - err := b.EnsureVaultAPI(ctx, req.Storage) - if err != nil { + func(ctx context.Context, _ *logical.Request, ownerID string) (map[string]interface{}, error) { + if err := b.EnsureVaultAPI(ctx, req.Storage); err != nil { return nil, err } + vaultClient := b.currentVaultClient() + if vaultClient == nil { + return nil, fmt.Errorf("vault API is not configured") + } cfg, err := base.GetConfigurationFromStorage[*ConfigAccess](ctx, b.BaseBackend, req.Storage, ConfigAccessKey, @@ -85,8 +93,8 @@ func (b *Backend) Initialize(ctx context.Context, req *logical.InitializationReq return nil, err } - newPolicies := cfg.Policies //[]string{"test-pgate"} // to be fetched from config - existingPolicies, err := AddPoliciesToEntity(ctx, b.VaultClient, ownerID, newPolicies) + newPolicies := cfg.Policies + existingPolicies, err := AddPoliciesToEntity(ctx, vaultClient, ownerID, newPolicies) if err != nil { return nil, err } @@ -95,59 +103,129 @@ func (b *Backend) Initialize(ctx context.Context, req *logical.InitializationReq "previous_policies": existingPolicies, "new_policies": newPolicies, }, nil - }), - (func(ctx context.Context, requ *logical.Request, ownerID string, internalData map[string]interface{}) error { // Append - err := b.EnsureVaultAPI(ctx, req.Storage) - if err != nil { + }, + func(ctx context.Context, _ *logical.Request, ownerID string, internalData map[string]interface{}) error { + if err := b.EnsureVaultAPI(ctx, req.Storage); err != nil { return err } + vaultClient := b.currentVaultClient() + if vaultClient == nil { + return fmt.Errorf("vault API is not configured") + } policies := Subtract[string]( InterfaceSliceToStringsStrict(internalData["previous_policies"].([]interface{})), InterfaceSliceToStringsStrict(internalData["new_policies"].([]interface{})), ) - err = SetEntityPolicies(ctx, b.VaultClient, ownerID, policies) - - return err - }), + return SetEntityPolicies(ctx, vaultClient, ownerID, policies) + }, ) + b.Logger().Info("GatePlane Policy Gate initialized with default configuration", "path", ConfigAPIVaultKey, - "AppRoleID", config.RoleID, - "AppRoleMount", config.AppRoleMount, + "token_set", b.currentVaultClient() != nil, "already_set", exists, ) return nil } func (b *Backend) EnsureVaultAPI(ctx context.Context, storage logical.Storage) error { - cfg, err := base.GetConfigurationFromStorage[*clientConfig.ConfigApiVaultAppRole](ctx, + b.Mutex.Lock() + clientReady := b.VaultClient != nil && b.vaultTokenWatcher != nil + client := b.VaultClient + b.Mutex.Unlock() + + if clientReady { + if _, err := client.Auth().Token().LookupSelfWithContext(ctx); err != nil { + return fmt.Errorf("checking configured vault token: %w", err) + } + return nil + } + + config, err := base.GetConfigurationFromStorage[*clientConfig.ConfigApiVaultPeriodicToken](ctx, b.BaseBackend, storage, ConfigAPIVaultKey, ) if err != nil { return err } - - if b.VaultClient == nil { - client, err := clients.NewVaultAppRoleClient(ctx, *cfg, nil) - if err != nil { - b.Logger().Error("[-] Could not create and authenticate the Vault API client", - "error", err, - ) - return err - } - b.VaultClient = client - return nil + if config.Token == "" { + return fmt.Errorf("vault API token is not configured") } - if err := clients.EnsureAuthenticationVault(ctx, - b.VaultClient, cfg.RoleID, cfg.RoleSecret, cfg.AppRoleMount, - ); err != nil { - b.Logger().Error("[-] Could not ensure authentication to the Vault API", - "error", err, - ) + client, tokenInfo, err := clients.NewVaultPeriodicTokenClient(ctx, config.Url, config.Token, nil) + if err != nil { return err } + return b.ReplaceVaultClient(client, tokenInfo) +} +func (b *Backend) ReplaceVaultClient(client *vault.Client, tokenInfo *clients.PeriodicTokenInfo) error { + if client == nil { + return fmt.Errorf("vault client is nil") + } + if tokenInfo == nil || tokenInfo.Secret == nil || tokenInfo.PeriodSeconds <= 0 { + return fmt.Errorf("periodic token information is invalid") + } + + watcher, err := client.NewLifetimeWatcher(&vault.LifetimeWatcherInput{ + Secret: tokenInfo.Secret, + Increment: tokenInfo.PeriodSeconds, + }) + if err != nil { + return fmt.Errorf("creating vault token lifetime watcher: %w", err) + } + + b.Mutex.Lock() + oldWatcher := b.vaultTokenWatcher + b.VaultClient = client + b.vaultTokenWatcher = watcher + b.vaultTokenPeriod = tokenInfo.PeriodSeconds + b.Mutex.Unlock() + + if oldWatcher != nil { + oldWatcher.Stop() + } + + go b.runVaultTokenWatcher(watcher) return nil } + +func (b *Backend) runVaultTokenWatcher(watcher *vault.LifetimeWatcher) { + watcher.Start() + err := <-watcher.DoneCh() + + b.Mutex.Lock() + if b.vaultTokenWatcher == watcher { + b.vaultTokenWatcher = nil + } + b.Mutex.Unlock() + + if err != nil { + b.Logger().Error("Vault API periodic token renewal stopped", "error", err) + } +} + +func (b *Backend) CleanupVaultClient(_ context.Context) { + b.Mutex.Lock() + watcher := b.vaultTokenWatcher + b.vaultTokenWatcher = nil + b.VaultClient = nil + b.vaultTokenPeriod = 0 + b.Mutex.Unlock() + + if watcher != nil { + watcher.Stop() + } +} + +func (b *Backend) VaultTokenStatus() (bool, int) { + b.Mutex.Lock() + defer b.Mutex.Unlock() + return b.VaultClient != nil, b.vaultTokenPeriod +} + +func (b *Backend) currentVaultClient() *vault.Client { + b.Mutex.Lock() + defer b.Mutex.Unlock() + return b.VaultClient +} diff --git a/pkg/responses/config.api.vault.go b/pkg/responses/config.api.vault.go index 90bc0c4..30f222d 100644 --- a/pkg/responses/config.api.vault.go +++ b/pkg/responses/config.api.vault.go @@ -11,8 +11,7 @@ package responses type ConfigApiVaultResponse struct { - Url string `json:"url"` - RoleID string `json:"role_id"` - AppRoleMount string `json:"approle_mount"` - RoleSecretSet bool `json:"role_secret_set"` + Url string `json:"url"` + TokenSet bool `json:"token_set"` + TokenPeriod int `json:"token_period"` } From 3c44eb77f38ce8b6b0f85571768517d295cd00db Mon Sep 17 00:00:00 2001 From: John Torakis Date: Thu, 23 Jul 2026 13:05:01 +0300 Subject: [PATCH 3/4] fix(test): allow upgrading tf providers --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e9b170e..cb2d3c8 100644 --- a/Makefile +++ b/Makefile @@ -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 From 730a12b88dbb59e7e88e77eff1b65f9742307820 Mon Sep 17 00:00:00 2001 From: John Torakis Date: Sun, 26 Jul 2026 22:14:00 +0300 Subject: [PATCH 4/4] feat(policygate): return `token_accessor` of the unwrapped Vault/OpenBao token --- internal/clients/vault.go | 10 +++++++++ .../policy-gate/endpoint.config.api.vault.go | 21 ++++++++++++++----- internal/policy-gate/plugin.go | 15 +++++++------ pkg/responses/config.api.vault.go | 7 ++++--- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/internal/clients/vault.go b/internal/clients/vault.go index c6154d4..7f49b02 100644 --- a/internal/clients/vault.go +++ b/internal/clients/vault.go @@ -23,6 +23,7 @@ import ( type PeriodicTokenInfo struct { Secret *api.Secret + Accessor string PeriodSeconds int } @@ -156,8 +157,17 @@ func ValidatePeriodicOrphanToken(ctx context.Context, client *api.Client) (*Peri } } + accessor, err := lookup.TokenAccessor() + if err != nil { + 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(), diff --git a/internal/policy-gate/endpoint.config.api.vault.go b/internal/policy-gate/endpoint.config.api.vault.go index 9e3cc1a..9172d51 100644 --- a/internal/policy-gate/endpoint.config.api.vault.go +++ b/internal/policy-gate/endpoint.config.api.vault.go @@ -99,7 +99,17 @@ func (b *Backend) handleConfigApiVaultUpdate(ctx context.Context, req *logical.R return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrMissingRequiredState } - return &logical.Response{}, nil + responseData, err := base.StructToMap(responses.ConfigApiVaultResponse{ + Url: config.Url, + TokenSet: true, + TokenPeriod: tokenInfo.PeriodSeconds, + TokenAccessor: tokenInfo.Accessor, + }) + if err != nil { + return logical.ErrorResponse(fmt.Sprint(err)), nil + } + + return &logical.Response{Data: responseData}, nil } func (b *Backend) handleConfigApiVaultRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { @@ -111,11 +121,12 @@ func (b *Backend) handleConfigApiVaultRead(ctx context.Context, req *logical.Req return logical.ErrorResponse(fmt.Sprint(err)), logical.ErrMissingRequiredState } - tokenSet, tokenPeriod := b.VaultTokenStatus() + tokenSet, tokenPeriod, tokenAccessor := b.VaultTokenStatus() responseData, err := base.StructToMap(responses.ConfigApiVaultResponse{ - Url: config.Url, - TokenSet: tokenSet || config.Token != "", - TokenPeriod: tokenPeriod, + Url: config.Url, + TokenSet: tokenSet || config.Token != "", + TokenPeriod: tokenPeriod, + TokenAccessor: tokenAccessor, }) if err != nil { return logical.ErrorResponse(fmt.Sprint(err)), nil diff --git a/internal/policy-gate/plugin.go b/internal/policy-gate/plugin.go index 4780b4d..eecf39f 100644 --- a/internal/policy-gate/plugin.go +++ b/internal/policy-gate/plugin.go @@ -29,10 +29,11 @@ const ConfigAccessKey = "config/access" type Backend struct { *base.BaseBackend - Mutex sync.Mutex - VaultClient *vault.Client - vaultTokenWatcher *vault.LifetimeWatcher - vaultTokenPeriod int + Mutex sync.Mutex + VaultClient *vault.Client + vaultTokenWatcher *vault.LifetimeWatcher + vaultTokenPeriod int + vaultTokenAccessor string } func (b *Backend) Initialize(ctx context.Context, req *logical.InitializationRequest) error { @@ -180,6 +181,7 @@ func (b *Backend) ReplaceVaultClient(client *vault.Client, tokenInfo *clients.Pe b.VaultClient = client b.vaultTokenWatcher = watcher b.vaultTokenPeriod = tokenInfo.PeriodSeconds + b.vaultTokenAccessor = tokenInfo.Accessor b.Mutex.Unlock() if oldWatcher != nil { @@ -211,6 +213,7 @@ func (b *Backend) CleanupVaultClient(_ context.Context) { b.vaultTokenWatcher = nil b.VaultClient = nil b.vaultTokenPeriod = 0 + b.vaultTokenAccessor = "" b.Mutex.Unlock() if watcher != nil { @@ -218,10 +221,10 @@ func (b *Backend) CleanupVaultClient(_ context.Context) { } } -func (b *Backend) VaultTokenStatus() (bool, int) { +func (b *Backend) VaultTokenStatus() (bool, int, string) { b.Mutex.Lock() defer b.Mutex.Unlock() - return b.VaultClient != nil, b.vaultTokenPeriod + return b.VaultClient != nil, b.vaultTokenPeriod, b.vaultTokenAccessor } func (b *Backend) currentVaultClient() *vault.Client { diff --git a/pkg/responses/config.api.vault.go b/pkg/responses/config.api.vault.go index 30f222d..f6a834d 100644 --- a/pkg/responses/config.api.vault.go +++ b/pkg/responses/config.api.vault.go @@ -11,7 +11,8 @@ package responses type ConfigApiVaultResponse struct { - Url string `json:"url"` - TokenSet bool `json:"token_set"` - TokenPeriod int `json:"token_period"` + Url string `json:"url"` + TokenSet bool `json:"token_set"` + TokenPeriod int `json:"token_period"` + TokenAccessor string `json:"token_accessor"` }