Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
/c/lib/welf/build
.help
/c/vendor/libuv.a
/.pi
/.pp
86 changes: 85 additions & 1 deletion pkg/signver/hashivault/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,90 @@ This solves the 10-minute token expiry issue in GitHub Actions by obtaining a ne
> available in GitHub Actions jobs with `id-token: write` permission.
> See [GitHub OIDC documentation](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#oidc-token-permissions).

## Programmatic configuration (`VaultOpts`)

Besides environment variables, the loader can be configured in code through
`KeyOpts.SignerVerifierOpts.VaultOpts` passed to `signver.NewSignerVerifier`.
`VaultOpts` is defined in this package.

#### Top-level fields

| Field | Env equivalent | Kind |
|---------------------------|------------------------------|-----------|
| `Address` | `VAULT_ADDR` | Transport |
| `TransitSecretEnginePath` | `TRANSIT_SECRET_ENGINE_PATH` | Transport |
| `Auth` | — | Auth |

`Auth` is a `*VaultAuth` that selects **exactly one** authentication method by
setting exactly one of its variant fields. Setting zero or more than one is a
configuration error.

| `VaultAuth` field | Type | Method |
|-------------------|----------------|---------------------|
| `AppRole` | `*AppRoleAuth` | Vault AppRole |
| `OIDC` | `*OIDCAuth` | GitHub Actions OIDC |
| `JWT` | `*JWTAuth` | Static JWT |
| `Token` | `*TokenAuth` | Static Vault token |

Per-method fields:

| Variant | Fields | Env equivalent |
|----------------|---------------------------------------|-------------------------------------------------------------------------------------------------|
| `AppRoleAuth` | `RoleID`, `SecretID`, `Path` | `WERF_VAULT_AUTH_ROLE_ID` (or `VAULT_ROLE_ID`), `WERF_VAULT_AUTH_SECRET_ID` (or `VAULT_SECRET_ID`), `WERF_VAULT_AUTH_PATH` |
| `OIDCAuth` | `Audience`, `Role`, `Path` | `WERF_ACTIONS_AUDIENCE`, `WERF_VAULT_AUTH_ROLE`, `WERF_VAULT_AUTH_PATH` |
| `JWTAuth` | `JWT`, `Role`, `Path` | `WERF_VAULT_AUTH_JWT`, `WERF_VAULT_AUTH_ROLE`, `WERF_VAULT_AUTH_PATH` |
| `TokenAuth` | `Token` | `VAULT_TOKEN` |

`Path` defaults to `ar` for AppRole and `jwt` for JWT/OIDC when left empty.

### Transport vs. auth options

Transport options (`Address`, `TransitSecretEnginePath`) are always resolved
independently, falling back to their environment variables and defaults when
left empty. They never affect how authentication credentials are sourced.

The **authentication source** is selected by `Auth`:

- `Auth == nil` — env-mode: credentials are read from the environment,
identical to the env-only behavior above (including the priority AppRole >
GitHub Actions OIDC > static JWT > static token and the `VAULT_TOKEN` /
`~/.vault-token` fallback).
- `Auth != nil` — opts-mode: credentials are taken **only** from the selected
variant; environment auth variables are ignored. There is no priority chain —
exactly one method must be set explicitly.

This lets you, for example, set `Address` in code while still authenticating
from CI environment variables (leave `Auth` nil).

### Example

```go
sv, err := signver.NewSignerVerifier(ctx, "", "", signver.KeyOpts{
KeyRef: "hashivault://my-key",
SignerVerifierOpts: signver.SignerVerifierOpts{
VaultOpts: hashivault.VaultOpts{
Address: "https://vault.example.com",
Auth: &hashivault.VaultAuth{
AppRole: &hashivault.AppRoleAuth{
RoleID: "role",
SecretID: "secret",
Path: "approle",
},
},
},
},
})
```

> **Note:** in opts-mode, incomplete auth options (for example, `AppRole` with
> `RoleID` but no `SecretID`), no method set, or multiple methods set return an
> error. The loader does **not** silently fall back to `VAULT_TOKEN` or
> `~/.vault-token`, so it never signs under an unexpected host identity.
>
> GitHub Actions OIDC (`OIDCAuth`) still reads `ACTIONS_ID_TOKEN_REQUEST_URL`
> and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` from the environment, as those are
> injected by GitHub Actions.

### Authentication flow

All authenticators that obtain a Vault token via login (`jwtAuthenticator`, `appRoleAuthenticator`) cache the
Expand All @@ -63,7 +147,7 @@ No extra HTTP requests between Vault operations, only the operation itself.
sign() / verify() / public() → auth.Login()
├─ isTokenValid() = false → obtain new credentials
│ ├─ AppRole: role_id + secret_id (no additional calls)
│ ├─ JWT (static): cached JWT from WERF_VAULT_AUTH_JWT (no additional calls)
│ ├─ JWT (static): cached JWT from WERF_VAULT_AUTH_JWT (env-mode) or JWTAuth.JWT (opts-mode) (no additional calls)
│ └─ JWT (GitHub Actions): GET ACTIONS_ID_TOKEN_REQUEST_URL → fresh JWT
├─ POST /auth/jwt/login (or /auth/ar/login) → new Vault token
│ cached as token_id with TTL from response
Expand Down
8 changes: 6 additions & 2 deletions pkg/signver/hashivault/auth_approle.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ type appRoleAuthenticator struct {
secretID string
}

func newAppRoleAuthenticator(roleID, secretID string) *appRoleAuthenticator {
func newAppRoleAuthenticator(roleID, secretID, authPath string) *appRoleAuthenticator {
if authPath == "" {
authPath = "ar"
}

return &appRoleAuthenticator{
baseAuthenticator: baseAuthenticator{
authPath: "ar",
authPath: authPath,
},
roleID: roleID,
secretID: secretID,
Expand Down
9 changes: 1 addition & 8 deletions pkg/signver/hashivault/auth_base.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type baseAuthenticator struct {
}

func (b *baseAuthenticator) login(client *vault.Client, data map[string]interface{}) error {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR: the auth mount path is now captured once when the authenticator is constructed and used immutably here, whereas previously it was re-read from WERF_VAULT_AUTH_PATH on every login. A change to that env var after construction but before a token refresh no longer takes effect. In practice the authenticator is built fresh per LoadSignerVerifier call, so this only matters for a long-lived instance whose environment mutates mid-lifetime — practically never. Informational; the immutable design is arguably cleaner. If per-login env re-read is a required behavior, restore a dynamic path resolver for the env path and add a refresh-after-mutation test.
(generated by pi-pi)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this only matters for a long-lived instance whose environment mutates mid-lifetime — practically never
I think we can ignore it, it is not our case

resp, err := client.Logical().Write(fmt.Sprintf("/auth/%s/login", b.getAuthPath()), data)
resp, err := client.Logical().Write(fmt.Sprintf("/auth/%s/login", b.authPath), data)
if err != nil {
return fmt.Errorf("vault write: %w", err)
}
Expand Down Expand Up @@ -63,13 +63,6 @@ func (b *baseAuthenticator) isTokenValid() bool {
return elapsed < b.tokenTTL-30*time.Second
}

func (b *baseAuthenticator) getAuthPath() string {
if authPath := getVaultAuthPath(); authPath != "" {
return authPath
}
return b.authPath
}

func (b *baseAuthenticator) Login(client *vault.Client) error {
panic("not implemented")
}
Expand Down
102 changes: 93 additions & 9 deletions pkg/signver/hashivault/auth_factory.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
package hashivault

import "fmt"
import (
"fmt"
)

func newAuthenticator(token string) (authenticator, error) {
if roleID, secretID := getVaultAuthRoleId(), getVaultAuthSecretId(); roleID != "" && secretID != "" {
return newAppRoleAuthenticator(roleID, secretID), nil
} else if audience := getActionsAudience(); audience != "" {
func newAuthenticator(opts VaultOpts) (authenticator, error) {
if opts.Auth == nil {
return newAuthenticatorFromEnv()
}
return newAuthenticatorFromOpts(opts.Auth)
}

func newAuthenticatorFromEnv() (authenticator, error) {
roleID := getVaultAuthRoleId()
secretID := getVaultAuthSecretId()
authPath := getVaultAuthPath()
audience := getActionsAudience()
authRole := getVaultAuthRole()
jwtToken := getVaultAuthJwt()

if roleID != "" && secretID != "" {
return newAppRoleAuthenticator(roleID, secretID, authPath), nil
} else if audience != "" {
requestURL := getActionsIDTokenRequestURL()
requestToken := getActionsIDTokenRequestToken()
if requestURL == "" {
Expand All @@ -15,15 +31,83 @@ func newAuthenticator(token string) (authenticator, error) {
return nil, fmt.Errorf("WERF_ACTIONS_AUDIENCE is set but ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing")
}
provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, audience)
return newJWTAuthenticator(provider, getVaultAuthRole()), nil
} else if jwtToken := getVaultAuthJwt(); jwtToken != "" {
return newJWTAuthenticator(provider, authRole, authPath), nil
} else if jwtToken != "" {
provider := newStaticJwtTokenProvider(jwtToken)
return newJWTAuthenticator(provider, getVaultAuthRole()), nil
return newJWTAuthenticator(provider, authRole, authPath), nil
}

token, err := getVaultToken(token)
token, err := getVaultToken("")
if err != nil {
return nil, err
}
return newStaticAuthProvider(token), nil
}

func newAuthenticatorFromOpts(auth *VaultAuth) (authenticator, error) {
if err := auth.validate(); err != nil {
return nil, err
}

switch {
case auth.AppRole != nil:
ar := auth.AppRole
if ar.RoleID == "" || ar.SecretID == "" {
return nil, fmt.Errorf("incomplete Vault auth options: AppRole requires both RoleID and SecretID")
}
return newAppRoleAuthenticator(ar.RoleID, ar.SecretID, ar.Path), nil
case auth.OIDC != nil:
o := auth.OIDC
if o.Audience == "" {
return nil, fmt.Errorf("incomplete Vault auth options: OIDC requires Audience")
}
requestURL := getActionsIDTokenRequestURL()
requestToken := getActionsIDTokenRequestToken()
if requestURL == "" {
return nil, fmt.Errorf("OIDC audience is configured but ACTIONS_ID_TOKEN_REQUEST_URL is missing")
}
if requestToken == "" {
return nil, fmt.Errorf("OIDC audience is configured but ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing")
}
provider := newActionsOidcJwtTokenProvider(requestURL, requestToken, o.Audience)
return newJWTAuthenticator(provider, o.Role, o.Path), nil
case auth.JWT != nil:
j := auth.JWT
if j.JWT == "" {
return nil, fmt.Errorf("incomplete Vault auth options: JWT requires JWT")
}
provider := newStaticJwtTokenProvider(j.JWT)
return newJWTAuthenticator(provider, j.Role, j.Path), nil
case auth.Token != nil:
if auth.Token.Token == "" {
return nil, fmt.Errorf("incomplete Vault auth options: Token requires Token")
}
return newStaticAuthProvider(auth.Token.Token), nil
default:
return nil, fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token")
}
}

// validate ensures exactly one auth method variant is set.
func (a *VaultAuth) validate() error {
n := 0
if a.AppRole != nil {
n++
}
if a.OIDC != nil {
n++
}
if a.JWT != nil {
n++
}
if a.Token != nil {
n++
}
if n == 0 {
return fmt.Errorf("incomplete Vault auth options: set exactly one of AppRole/OIDC/JWT/Token")
}
if n > 1 {
return fmt.Errorf("ambiguous Vault auth options: multiple auth methods set, set exactly one of AppRole/OIDC/JWT/Token")
}
return nil
}
Loading
Loading