mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 20:56:21 +00:00
Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size.
Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions.
Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control.
Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth.
Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities.
1979 lines
64 KiB
Go
1979 lines
64 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
vault "github.com/hashicorp/vault-client-go"
|
|
"github.com/hashicorp/vault-client-go/schema"
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/iamapi/iamerr"
|
|
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
|
"github.com/versity/versitygw/iamapi/types"
|
|
)
|
|
|
|
const vaultRequestTimeout = 10 * time.Second
|
|
|
|
// maxCASRetries bounds the read-modify-write retry loop withUserCAS/
|
|
// withRoleCAS/withOIDCProviderCAS run when a version-checked (CAS) write
|
|
// loses a race against a concurrent writer updating the same entity —
|
|
// mirroring the 3-attempt collision-retry loops already used elsewhere in
|
|
// this package for ID generation (see controller.go's CreateUser/CreateRole/
|
|
// CreateAccessKey).
|
|
const maxCASRetries = 3
|
|
|
|
// errConcurrentModification is withUserCAS/withRoleCAS/withOIDCProviderCAS's
|
|
// internal signal that a replace* call's CAS write lost a race against
|
|
// another writer and should be retried; it never escapes to a caller
|
|
// directly — once retries are exhausted it's surfaced as
|
|
// iamerr.ConcurrentModification(), matching real IAM's documented
|
|
// ConcurrentModificationException.
|
|
var errConcurrentModification = errors.New("iamapi: concurrent modification")
|
|
|
|
// errRenameCleanupFailed marks an error from deleteOldUserAfterRename: the
|
|
// rename's new record was created successfully, but deleting the stale
|
|
// record at the old name failed even after retrying (see
|
|
// renameDeleteRetries). It is surfaced only via errors.Is/wrapping —
|
|
// Vault's KV store has no multi-key transaction to make the two writes
|
|
// atomic, so this signals a state that needs operator attention rather than
|
|
// one an automatic retry of the whole operation can resolve (a caller
|
|
// retrying UpdateUser from scratch would now fail with EntityAlreadyExists
|
|
// against the very record it just created).
|
|
var errRenameCleanupFailed = errors.New("iamapi: rename cleanup failed")
|
|
|
|
// kvVersion extracts a KV v2 secret version from a read response's metadata
|
|
// map. The generated schema client types Metadata as map[string]interface{},
|
|
// but vault-client-go decodes its JSON body with a decoder configured to
|
|
// produce json.Number for numeric fields, not float64 — a plain
|
|
// metadata["version"].(float64) assertion never matches, so it silently fell
|
|
// through to the zero value on every call. Every version-checked (CAS)
|
|
// write's readVersion was therefore always 0 — the "create if it doesn't
|
|
// exist yet" sentinel — so any write to an already-existing document (i.e.
|
|
// every one of them past its first) sent cas:0 and was unconditionally
|
|
// rejected by Vault as a check-and-set mismatch. That surfaced as
|
|
// ConcurrentModificationException on withUserCAS/withRoleCAS/
|
|
// withOIDCProviderCAS's every retry, deterministically, with no concurrent
|
|
// writer involved at all — confirmed by reproducing it single-threaded
|
|
// against a live Vault (CreateRole then PutRolePolicy, nothing else
|
|
// touching the record, still failed every time before this fix).
|
|
func kvVersion(metadata map[string]any) int32 {
|
|
switch v := metadata["version"].(type) {
|
|
case json.Number:
|
|
if n, err := v.Int64(); err == nil {
|
|
return int32(n)
|
|
}
|
|
case float64:
|
|
return int32(v)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// VaultConfig holds all configuration options for the Vault-backed IAM storer.
|
|
type VaultConfig struct {
|
|
EndpointURL string
|
|
Namespace string
|
|
SecretStoragePath string
|
|
SecretStorageNamespace string
|
|
AuthMethod string
|
|
AuthNamespace string
|
|
MountPath string
|
|
RootToken string
|
|
RoleID string
|
|
RoleSecret string
|
|
ServerCert string
|
|
ClientCert string
|
|
ClientCertKey string
|
|
}
|
|
|
|
// VaultStore is a Vault KV v2-backed implementation of Storer.
|
|
type VaultStore struct {
|
|
client *vault.Client
|
|
authReqOpts []vault.RequestOption
|
|
kvReqOpts []vault.RequestOption
|
|
secretStoragePath string
|
|
creds schema.AppRoleLoginRequest
|
|
}
|
|
|
|
var _ Storer = (*VaultStore)(nil)
|
|
|
|
func NewVault(cfg VaultConfig) (Storer, error) {
|
|
opts := []vault.ClientOption{
|
|
vault.WithAddress(strings.TrimSpace(cfg.EndpointURL)),
|
|
vault.WithRequestTimeout(vaultRequestTimeout),
|
|
}
|
|
|
|
serverCert := strings.TrimSpace(cfg.ServerCert)
|
|
clientCert := strings.TrimSpace(cfg.ClientCert)
|
|
clientCertKey := strings.TrimSpace(cfg.ClientCertKey)
|
|
|
|
if serverCert != "" {
|
|
tls := vault.TLSConfiguration{}
|
|
tls.ServerCertificate.FromBytes = []byte(serverCert)
|
|
if clientCert != "" {
|
|
if clientCertKey == "" {
|
|
return nil, fmt.Errorf("client certificate and client certificate key should both be specified")
|
|
}
|
|
tls.ClientCertificate.FromBytes = []byte(clientCert)
|
|
tls.ClientCertificateKey.FromBytes = []byte(clientCertKey)
|
|
}
|
|
opts = append(opts, vault.WithTLS(tls))
|
|
}
|
|
|
|
client, err := vault.New(opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("init vault client: %w", err)
|
|
}
|
|
|
|
authMethod := strings.TrimSpace(cfg.AuthMethod)
|
|
mountPath := strings.TrimSpace(cfg.MountPath)
|
|
|
|
authReqOpts := []vault.RequestOption{}
|
|
if authMethod != "" {
|
|
authReqOpts = append(authReqOpts, vault.WithMountPath(authMethod))
|
|
}
|
|
|
|
kvReqOpts := []vault.RequestOption{}
|
|
if mountPath != "" {
|
|
kvReqOpts = append(kvReqOpts, vault.WithMountPath(mountPath))
|
|
}
|
|
|
|
// Resolve namespaces: specific namespace overrides the generic fallback.
|
|
authNS := strings.TrimSpace(cfg.AuthNamespace)
|
|
secretNS := strings.TrimSpace(cfg.SecretStorageNamespace)
|
|
fallback := strings.TrimSpace(cfg.Namespace)
|
|
if authNS == "" {
|
|
authNS = fallback
|
|
}
|
|
if secretNS == "" {
|
|
secretNS = fallback
|
|
}
|
|
|
|
rootToken := strings.TrimSpace(cfg.RootToken)
|
|
roleID := strings.TrimSpace(cfg.RoleID)
|
|
roleSecret := strings.TrimSpace(cfg.RoleSecret)
|
|
|
|
// AppRole tokens are namespace-scoped; cross-namespace use requires a root token.
|
|
if rootToken == "" && authNS != "" && secretNS != "" && authNS != secretNS {
|
|
return nil, fmt.Errorf(
|
|
"approle tokens are namespace scoped. auth namespace %q and secret storage namespace %q differ. "+
|
|
"use the same namespace or authenticate with a root token",
|
|
authNS, secretNS,
|
|
)
|
|
}
|
|
|
|
if rootToken == "" && authNS != "" {
|
|
authReqOpts = append(authReqOpts, vault.WithNamespace(authNS))
|
|
}
|
|
if secretNS != "" {
|
|
kvReqOpts = append(kvReqOpts, vault.WithNamespace(secretNS))
|
|
}
|
|
|
|
creds := schema.AppRoleLoginRequest{
|
|
RoleId: roleID,
|
|
SecretId: roleSecret,
|
|
}
|
|
|
|
switch {
|
|
case rootToken != "":
|
|
if err := client.SetToken(rootToken); err != nil {
|
|
return nil, fmt.Errorf("root token authentication failure: %w", err)
|
|
}
|
|
case roleID != "":
|
|
if roleSecret == "" {
|
|
return nil, fmt.Errorf("role id and role secret must both be specified")
|
|
}
|
|
resp, err := client.Auth.AppRoleLogin(context.Background(), creds, authReqOpts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("approle authentication failure: %w", err)
|
|
}
|
|
if err := client.SetToken(resp.Auth.ClientToken); err != nil {
|
|
return nil, fmt.Errorf("approle authentication set token failure: %w", err)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("vault authentication requires either roleid/rolesecret or root token")
|
|
}
|
|
|
|
secretStoragePath := strings.TrimSpace(cfg.SecretStoragePath)
|
|
if secretStoragePath == "" {
|
|
secretStoragePath = "iam"
|
|
}
|
|
|
|
return &VaultStore{
|
|
client: client,
|
|
authReqOpts: authReqOpts,
|
|
kvReqOpts: kvReqOpts,
|
|
secretStoragePath: secretStoragePath,
|
|
creds: creds,
|
|
}, nil
|
|
}
|
|
|
|
// reAuthIfNeeded attempts AppRole re-authentication when vault returns 403.
|
|
// It returns nil only when the original error was nil or re-auth succeeded.
|
|
func (s *VaultStore) reAuthIfNeeded(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !vault.IsErrorStatus(err, http.StatusForbidden) {
|
|
return err
|
|
}
|
|
resp, authErr := s.client.Auth.AppRoleLogin(context.Background(), s.creds, s.authReqOpts...)
|
|
if authErr != nil {
|
|
return fmt.Errorf("vault re-authentication failure: %w", authErr)
|
|
}
|
|
if err := s.client.SetToken(resp.Auth.ClientToken); err != nil {
|
|
return fmt.Errorf("vault re-authentication set token failure: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// usersPath is the KV prefix under which users are stored, kept distinct
|
|
// from rolesPath/oidcProvidersPath/sessionsPath — mirroring their own
|
|
// isolation rationale — so listing users never picks up a sibling entity
|
|
// type's directory marker (e.g. "roles/") as if it were a username.
|
|
func (s *VaultStore) usersPath() string {
|
|
return s.secretStoragePath + "/users"
|
|
}
|
|
|
|
// caseFoldKey case-folds name to the KV path segment (and inner data map
|
|
// key) an identity of that name is stored under. AWS enforces
|
|
// case-insensitive uniqueness for IAM names (UserName, RoleName) but
|
|
// Vault's KV paths are plain case-sensitive strings; storing every identity
|
|
// under its case-folded name — rather than the as-given casing, resolved by
|
|
// a separate list-and-compare lookup — makes uniqueness a property Vault's
|
|
// own CAS write enforces atomically, instead of a check-then-write race
|
|
// between two callers using different casings of the same name (e.g.
|
|
// "Alice" and "alice" both passing a list-based existence check and then
|
|
// both succeeding at CAS 0 on two different paths). The original,
|
|
// as-given casing is preserved in the identity's own UserName/RoleName
|
|
// field within the stored document.
|
|
func caseFoldKey(name string) string {
|
|
return strings.ToLower(name)
|
|
}
|
|
|
|
func (s *VaultStore) CreateUser(_ context.Context, user types.User) (*types.User, error) {
|
|
key := caseFoldKey(user.UserName)
|
|
|
|
userMap, err := userToVaultMap(user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize user: %w", err)
|
|
}
|
|
|
|
path := s.usersPath() + "/" + key
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{key: userMap},
|
|
Options: map[string]any{
|
|
"cas": 0,
|
|
},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
// retry once after re-auth
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, iamerr.EntityAlreadyExistsUser(user.UserName)
|
|
}
|
|
if vault.IsErrorStatus(err, http.StatusForbidden) {
|
|
return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
return cloneUser(user), nil
|
|
}
|
|
|
|
// DeleteUser checks user against its dependency preconditions (no inline
|
|
// policies, no access keys) and then deletes it. The metadata-delete call
|
|
// Vault exposes has no CAS parameter of its own (unlike a KV write), so a
|
|
// plain read-check-then-delete would leave a window where a concurrent
|
|
// CreateAccessKey or PutUserPolicy lands between the check and the delete,
|
|
// and the delete proceeds anyway, orphaning the new key/policy against a
|
|
// user that no longer exists. Closing that window: after the
|
|
// dependency check, replaceUser writes the same (unchanged) record back
|
|
// with a CAS matching the version just read — succeeding only if nothing
|
|
// else has modified the record since — immediately before the actual
|
|
// delete, shrinking the race to the gap between two back-to-back Vault
|
|
// calls instead of the whole request lifecycle. A CAS conflict there means
|
|
// something changed after the check, so the whole check is retried
|
|
// (bounded by maxCASRetries) rather than deleting against stale
|
|
// information.
|
|
func (s *VaultStore) DeleteUser(ctx context.Context, username string) error {
|
|
for range maxCASRetries {
|
|
user, version, err := s.readUserVersion(username)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(user.Policies.Inline) > 0 {
|
|
return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)
|
|
}
|
|
if len(user.AccessKeys) > 0 {
|
|
return iamerr.GetAPIError(iamerr.ErrDeleteConflict)
|
|
}
|
|
|
|
if _, err := s.replaceUser(ctx, *user, version); err != nil {
|
|
if errors.Is(err, errConcurrentModification) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
return s.deleteByPath("users/" + caseFoldKey(user.UserName))
|
|
}
|
|
return iamerr.ConcurrentModification()
|
|
}
|
|
|
|
func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, error) {
|
|
user, _, err := s.readUserVersion(username)
|
|
return user, err
|
|
}
|
|
|
|
// readUserVersion resolves username the same way GetUser does, additionally
|
|
// returning the KV version the record was read at, so a mutation can write
|
|
// back with a matching CAS value instead of racing on a blind
|
|
// delete-then-recreate (see replaceUser).
|
|
func (s *VaultStore) readUserVersion(username string) (*types.User, int32, error) {
|
|
key := caseFoldKey(username)
|
|
path := s.usersPath() + "/" + key
|
|
resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, 0, iamerr.NoSuchEntityUser(username)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, 0, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, 0, iamerr.NoSuchEntityUser(username)
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
user, err := parseVaultUser(resp.Data.Data, key)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return cloneUser(user), kvVersion(resp.Data.Metadata), nil
|
|
}
|
|
|
|
// GetUserByAccessKeyID has no index to consult (unlike InternalStore's
|
|
// AccessKeyIndex) so it scans every user's access keys, mirroring
|
|
// GetAccessKeyLastUsed's existing linear scan.
|
|
func (s *VaultStore) GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error) {
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for _, username := range resp.Data.Keys {
|
|
user, err := s.GetUser(ctx, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, key := range user.AccessKeys {
|
|
if key.AccessKeyId == accessKeyID {
|
|
return user, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
|
|
func (s *VaultStore) ListUsers(ctx context.Context, input ListUsersInput) (*ListUsersOutput, error) {
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListUsersOutput{Users: []types.User{}}, nil
|
|
}
|
|
reauthErr := s.reAuthIfNeeded(err)
|
|
if reauthErr != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListUsersOutput{Users: []types.User{}}, nil
|
|
}
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListUsersOutput{Users: []types.User{}}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
users := make([]types.User, 0, len(resp.Data.Keys))
|
|
for _, key := range resp.Data.Keys {
|
|
user, err := s.GetUser(ctx, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if input.PathPrefix != "" && !strings.HasPrefix(user.Path, input.PathPrefix) {
|
|
continue
|
|
}
|
|
users = append(users, *user)
|
|
}
|
|
|
|
sort.Slice(users, func(i, j int) bool {
|
|
return users[i].UserName < users[j].UserName
|
|
})
|
|
|
|
start := 0
|
|
if input.Marker != "" {
|
|
start = len(users)
|
|
for i, user := range users {
|
|
if user.UserName == input.Marker {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
users = users[start:]
|
|
|
|
limit := len(users)
|
|
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
|
limit = int(input.MaxItems)
|
|
}
|
|
|
|
out := &ListUsersOutput{
|
|
Users: make([]types.User, limit),
|
|
}
|
|
copy(out.Users, users[:limit])
|
|
if limit < len(users) {
|
|
out.IsTruncated = true
|
|
out.Marker = out.Users[limit-1].UserName
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *VaultStore) UpdateUser(ctx context.Context, input UpdateUserInput) (*types.User, error) {
|
|
user, version, err := s.readUserVersion(input.UserName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
originalName := user.UserName
|
|
|
|
finalName := user.UserName
|
|
if input.NewUserName != "" {
|
|
finalName = input.NewUserName
|
|
}
|
|
|
|
if !strings.EqualFold(finalName, originalName) {
|
|
existing, err := s.GetUser(ctx, finalName)
|
|
if err != nil && !errors.Is(err, iamerr.NoSuchEntityUser(finalName)) {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
return nil, iamerr.EntityAlreadyExistsUser(finalName)
|
|
}
|
|
}
|
|
|
|
if input.NewPath != "" {
|
|
user.Path = input.NewPath
|
|
}
|
|
if input.NewUserName != "" {
|
|
user.UserName = input.NewUserName
|
|
}
|
|
if input.NewArn != "" {
|
|
user.Arn = input.NewArn
|
|
}
|
|
|
|
if caseFoldKey(user.UserName) != caseFoldKey(originalName) {
|
|
// A genuine rename to a different case-folded key (and therefore a
|
|
// different KV path): create at the new path first — its cas:0
|
|
// write atomically detects a conflict, including one from a
|
|
// concurrent create/rename racing for the same new name — before
|
|
// deleting the old entry. A UserName change that's case-only (e.g.
|
|
// "Alice" -> "alice") case-folds to the *same* path, so it's handled
|
|
// below as an in-place update instead: routing it through
|
|
// CreateUser here would spuriously fail with EntityAlreadyExists
|
|
// against the very record being renamed.
|
|
if _, err := s.CreateUser(ctx, *user); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.deleteOldUserAfterRename(originalName); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if _, err := s.replaceUser(ctx, *user, version); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cloneUser(*user), nil
|
|
}
|
|
|
|
// renameDeleteRetries bounds deleteOldUserAfterRename's retries of the
|
|
// old-path delete that follows a successful create-at-new-path during a
|
|
// rename (roles have no rename operation, so only users need this). Vault
|
|
// has no multi-key transaction to make "create new, delete old" atomic, so
|
|
// a delete failure here (after the new record already exists) is the one
|
|
// window where two live records for the same identity can coexist;
|
|
// retrying a bounded number of times, with a short backoff, absorbs a
|
|
// transient failure (network blip, momentary 403) rather than leaving that
|
|
// window open on the first error.
|
|
const (
|
|
renameDeleteRetries = 3
|
|
renameDeleteBackoff = 200 * time.Millisecond
|
|
)
|
|
|
|
// deleteOldUserAfterRename deletes the pre-rename user record at
|
|
// originalName after UpdateUser has already created the record at its new
|
|
// name, retrying up to renameDeleteRetries times. If every attempt fails,
|
|
// the error returned wraps errRenameCleanupFailed so callers/operators can
|
|
// recognize that the new record was created and the stale record at
|
|
// originalName still exists and needs manual removal — better than
|
|
// masking that state as an ordinary write error.
|
|
func (s *VaultStore) deleteOldUserAfterRename(originalName string) error {
|
|
var err error
|
|
for attempt := range renameDeleteRetries {
|
|
if attempt > 0 {
|
|
time.Sleep(renameDeleteBackoff)
|
|
}
|
|
if err = s.deleteByPath("users/" + caseFoldKey(originalName)); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("%w: stale user record %q must be removed manually: %v", errRenameCleanupFailed, originalName, err)
|
|
}
|
|
|
|
// replaceUser overwrites the stored document for user.UserName using a
|
|
// version-checked (CAS) write tied to readVersion — the KV version the
|
|
// caller most recently read the record at — instead of an unconditional
|
|
// delete-then-recreate. This way, two concurrent updates to the same user
|
|
// (e.g. a DeleteAccessKey revocation racing a PutUserPolicy call) can't
|
|
// have the second writer silently discard the first writer's change: a CAS
|
|
// mismatch fails with errConcurrentModification, for withUserCAS to retry.
|
|
func (s *VaultStore) replaceUser(ctx context.Context, user types.User, readVersion int32) (*types.User, error) {
|
|
userMap, err := userToVaultMap(user)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize user: %w", err)
|
|
}
|
|
|
|
key := caseFoldKey(user.UserName)
|
|
path := s.usersPath() + "/" + key
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{key: userMap},
|
|
Options: map[string]any{"cas": readVersion},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, errConcurrentModification
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, errConcurrentModification
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
return cloneUser(user), nil
|
|
}
|
|
|
|
// withUserCAS resolves username, applies mutate to the fetched user, and
|
|
// writes it back with a CAS matching the version it was read at, retrying
|
|
// (bounded by maxCASRetries) if a concurrent writer's update lands first —
|
|
// closing the lost-update race described in replaceUser's doc comment.
|
|
// mutate's own error (e.g. a quota or not-found error) is returned
|
|
// immediately, never retried — only a genuine CAS conflict is.
|
|
func (s *VaultStore) withUserCAS(ctx context.Context, username string, mutate func(*types.User) error) (*types.User, error) {
|
|
for range maxCASRetries {
|
|
user, version, err := s.readUserVersion(username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := mutate(user); err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := s.replaceUser(ctx, *user, version)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
if !errors.Is(err, errConcurrentModification) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, iamerr.ConcurrentModification()
|
|
}
|
|
|
|
func (s *VaultStore) CreateAccessKey(ctx context.Context, input CreateAccessKeyInput) (*types.AccessKey, error) {
|
|
var created types.AccessKey
|
|
if _, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error {
|
|
if len(user.AccessKeys) >= MaxAccessKeysPerUser {
|
|
return iamerr.AccessKeysLimitExceeded(MaxAccessKeysPerUser)
|
|
}
|
|
for _, key := range user.AccessKeys {
|
|
if key.AccessKeyId == input.AccessKeyID {
|
|
return ErrAccessKeyIDAlreadyExists
|
|
}
|
|
}
|
|
|
|
user.AccessKeys = append(user.AccessKeys, types.AccessKeyEntry{
|
|
AccessKeyId: input.AccessKeyID,
|
|
SecretAccessKey: input.SecretAccessKey,
|
|
Status: input.Status,
|
|
CreateDate: input.CreateDate,
|
|
})
|
|
created = types.AccessKey{
|
|
UserName: input.UserName,
|
|
AccessKeyId: input.AccessKeyID,
|
|
Status: input.Status,
|
|
SecretAccessKey: input.SecretAccessKey,
|
|
CreateDate: input.CreateDate,
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &created, nil
|
|
}
|
|
|
|
func (s *VaultStore) UpdateAccessKey(ctx context.Context, input UpdateAccessKeyInput) error {
|
|
_, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error {
|
|
for i, key := range user.AccessKeys {
|
|
if key.AccessKeyId == input.AccessKeyID {
|
|
user.AccessKeys[i].Status = input.Status
|
|
return nil
|
|
}
|
|
}
|
|
return iamerr.NoSuchEntityAccessKey(input.AccessKeyID)
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) DeleteAccessKey(ctx context.Context, username, accessKeyID string) error {
|
|
_, err := s.withUserCAS(ctx, username, func(user *types.User) error {
|
|
idx := -1
|
|
for i, key := range user.AccessKeys {
|
|
if key.AccessKeyId == accessKeyID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx == -1 {
|
|
return iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
user.AccessKeys = slices.Delete(user.AccessKeys, idx, idx+1)
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) GetAccessKeyLastUsed(ctx context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) {
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for _, username := range resp.Data.Keys {
|
|
user, err := s.GetUser(ctx, username)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, key := range user.AccessKeys {
|
|
if key.AccessKeyId == accessKeyID {
|
|
return &GetAccessKeyLastUsedOutput{
|
|
UserName: user.UserName,
|
|
LastUsedDate: key.LastUsedDate,
|
|
ServiceName: key.LastUsedService,
|
|
Region: key.LastUsedRegion,
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
|
|
// recordAccessKeyUsageTimeout bounds RecordAccessKeyUsage's detached
|
|
// background update.
|
|
const recordAccessKeyUsageTimeout = 5 * time.Second
|
|
|
|
// RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed metadata
|
|
// in its own background goroutine, detached from ctx, and always returns
|
|
// nil immediately: this runs on the hot path of every authenticated request
|
|
// (see iammiddleware.recordAccessKeyUsage), and a Vault round trip — plus,
|
|
// on a CAS conflict, withUserCAS's retry loop — is too expensive to add
|
|
// synchronously to every one of them. A failure (including one that
|
|
// exhausts those retries) is only logged, never surfaced: this is purely
|
|
// informational metadata, and a lost update under concurrent use is
|
|
// immaterial.
|
|
func (s *VaultStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error {
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), recordAccessKeyUsageTimeout)
|
|
defer cancel()
|
|
if err := s.recordAccessKeyUsage(ctx, accessKeyID, service, region, when); err != nil {
|
|
debuglogger.Logf("failed to record Vault access key last-used metadata for %q: %v", accessKeyID, err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (s *VaultStore) recordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error {
|
|
resp, err := s.client.Secrets.KvV2List(ctx, s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(ctx, s.usersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, username := range resp.Data.Keys {
|
|
user, err := s.GetUser(ctx, username)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !slices.ContainsFunc(user.AccessKeys, func(k types.AccessKeyEntry) bool { return k.AccessKeyId == accessKeyID }) {
|
|
continue
|
|
}
|
|
|
|
_, err = s.withUserCAS(ctx, username, func(u *types.User) error {
|
|
for i, key := range u.AccessKeys {
|
|
if key.AccessKeyId == accessKeyID {
|
|
u.AccessKeys[i].LastUsedDate = when
|
|
u.AccessKeys[i].LastUsedService = service
|
|
u.AccessKeys[i].LastUsedRegion = region
|
|
return nil
|
|
}
|
|
}
|
|
return iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
})
|
|
return err
|
|
}
|
|
|
|
return iamerr.NoSuchEntityAccessKey(accessKeyID)
|
|
}
|
|
|
|
func (s *VaultStore) ListAccessKeys(ctx context.Context, input ListAccessKeysInput) (*ListAccessKeysOutput, error) {
|
|
user, err := s.GetUser(ctx, input.UserName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keys := make([]types.AccessKeyMetadata, 0, len(user.AccessKeys))
|
|
for _, key := range user.AccessKeys {
|
|
keys = append(keys, types.AccessKeyMetadata{
|
|
UserName: input.UserName,
|
|
AccessKeyId: key.AccessKeyId,
|
|
Status: key.Status,
|
|
CreateDate: key.CreateDate,
|
|
})
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
return keys[i].AccessKeyId < keys[j].AccessKeyId
|
|
})
|
|
|
|
start := 0
|
|
if input.Marker != "" {
|
|
start = len(keys)
|
|
for i, key := range keys {
|
|
if key.AccessKeyId == input.Marker {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
keys = keys[start:]
|
|
|
|
limit := len(keys)
|
|
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
|
limit = int(input.MaxItems)
|
|
}
|
|
|
|
out := &ListAccessKeysOutput{
|
|
AccessKeys: make([]types.AccessKeyMetadata, limit),
|
|
}
|
|
copy(out.AccessKeys, keys[:limit])
|
|
if limit < len(keys) {
|
|
out.IsTruncated = true
|
|
out.Marker = out.AccessKeys[limit-1].AccessKeyId
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *VaultStore) PutUserPolicy(ctx context.Context, input PutUserPolicyInput) error {
|
|
_, err := s.withUserCAS(ctx, input.UserName, func(user *types.User) error {
|
|
newTotal := len(input.PolicyDocument)
|
|
replaceAt := -1
|
|
for i, p := range user.Policies.Inline {
|
|
if p.PolicyName == input.PolicyName {
|
|
replaceAt = i
|
|
continue
|
|
}
|
|
newTotal += len(p.PolicyDocument)
|
|
}
|
|
if newTotal > MaxInlinePolicyBytesPerUser {
|
|
return iamerr.InlinePolicyQuotaExceeded("user", input.UserName, MaxInlinePolicyBytesPerUser)
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
if replaceAt >= 0 {
|
|
user.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument
|
|
user.Policies.Inline[replaceAt].UpdateDate = now
|
|
} else {
|
|
user.Policies.Inline = append(user.Policies.Inline, types.PolicyEntry{
|
|
PolicyName: input.PolicyName,
|
|
PolicyDocument: input.PolicyDocument,
|
|
CreateDate: now,
|
|
UpdateDate: now,
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) GetUserPolicy(ctx context.Context, userName, policyName string) (*types.PolicyEntry, error) {
|
|
user, err := s.GetUser(ctx, userName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, p := range user.Policies.Inline {
|
|
if p.PolicyName == policyName {
|
|
cloned := p
|
|
return &cloned, nil
|
|
}
|
|
}
|
|
|
|
return nil, iamerr.NoSuchEntityUserPolicy(userName, policyName)
|
|
}
|
|
|
|
func (s *VaultStore) DeleteUserPolicy(ctx context.Context, userName, policyName string) error {
|
|
_, err := s.withUserCAS(ctx, userName, func(user *types.User) error {
|
|
idx := -1
|
|
for i, p := range user.Policies.Inline {
|
|
if p.PolicyName == policyName {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx == -1 {
|
|
return iamerr.NoSuchEntityUserPolicy(userName, policyName)
|
|
}
|
|
user.Policies.Inline = slices.Delete(user.Policies.Inline, idx, idx+1)
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) ListUserPolicies(ctx context.Context, input ListUserPoliciesInput) (*ListUserPoliciesOutput, error) {
|
|
user, err := s.GetUser(ctx, input.UserName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
names := make([]string, 0, len(user.Policies.Inline))
|
|
for _, p := range user.Policies.Inline {
|
|
names = append(names, p.PolicyName)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
start := 0
|
|
if input.Marker != "" {
|
|
start = len(names)
|
|
for i, name := range names {
|
|
if name == input.Marker {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
names = names[start:]
|
|
|
|
limit := len(names)
|
|
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
|
limit = int(input.MaxItems)
|
|
}
|
|
|
|
out := &ListUserPoliciesOutput{
|
|
PolicyNames: make([]string, limit),
|
|
}
|
|
copy(out.PolicyNames, names[:limit])
|
|
if limit < len(names) {
|
|
out.IsTruncated = true
|
|
out.Marker = out.PolicyNames[limit-1]
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// deleteByPath permanently removes a secret and all its versions without
|
|
// checking for existence first. relPath is relative to secretStoragePath
|
|
// (e.g. "users/alice" or "sessions/AKIA...").
|
|
func (s *VaultStore) deleteByPath(relPath string) error {
|
|
path := s.secretStoragePath + "/" + relPath
|
|
_, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// rolesPath is the KV prefix under which roles are stored, kept distinct
|
|
// from usersPath so listing one entity kind never has to filter out the
|
|
// other's keys.
|
|
func (s *VaultStore) rolesPath() string {
|
|
return s.secretStoragePath + "/roles"
|
|
}
|
|
|
|
func (s *VaultStore) CreateRole(_ context.Context, role types.Role) (*types.Role, error) {
|
|
key := caseFoldKey(role.RoleName)
|
|
|
|
role.EnsureRoleLastUsed()
|
|
|
|
roleMap, err := roleToVaultMap(role)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize role: %w", err)
|
|
}
|
|
|
|
path := s.rolesPath() + "/" + key
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{key: roleMap},
|
|
Options: map[string]any{
|
|
"cas": 0,
|
|
},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, iamerr.EntityAlreadyExistsRole(role.RoleName)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
// retry once after re-auth
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, iamerr.EntityAlreadyExistsRole(role.RoleName)
|
|
}
|
|
if vault.IsErrorStatus(err, http.StatusForbidden) {
|
|
return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
return cloneRole(role), nil
|
|
}
|
|
|
|
func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, error) {
|
|
role, _, err := s.readRoleVersion(roleName)
|
|
return role, err
|
|
}
|
|
|
|
// readRoleVersion is GetRole's counterpart to readUserVersion: it
|
|
// additionally returns the KV version the record was read at, so a
|
|
// mutation can write back with a matching CAS value instead of racing on a
|
|
// blind delete-then-recreate (see replaceRole).
|
|
func (s *VaultStore) readRoleVersion(roleName string) (*types.Role, int32, error) {
|
|
key := caseFoldKey(roleName)
|
|
path := s.rolesPath() + "/" + key
|
|
resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, 0, iamerr.NoSuchEntityRole(roleName)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, 0, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, 0, iamerr.NoSuchEntityRole(roleName)
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
role, err := parseVaultRole(resp.Data.Data, key)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return cloneRole(role), kvVersion(resp.Data.Metadata), nil
|
|
}
|
|
|
|
func (s *VaultStore) ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error) {
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListRolesOutput{Roles: []types.Role{}}, nil
|
|
}
|
|
reauthErr := s.reAuthIfNeeded(err)
|
|
if reauthErr != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListRolesOutput{Roles: []types.Role{}}, nil
|
|
}
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.rolesPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListRolesOutput{Roles: []types.Role{}}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
roles := make([]types.Role, 0, len(resp.Data.Keys))
|
|
for _, key := range resp.Data.Keys {
|
|
role, err := s.GetRole(ctx, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if input.PathPrefix != "" && !strings.HasPrefix(role.Path, input.PathPrefix) {
|
|
continue
|
|
}
|
|
// ListRoles entries omit RoleLastUsed even though GetRole (reused
|
|
// above to fetch each entry) attaches it — matches the documented
|
|
// list/get field asymmetry.
|
|
role.RoleLastUsed = nil
|
|
roles = append(roles, *role)
|
|
}
|
|
|
|
sort.Slice(roles, func(i, j int) bool {
|
|
return roles[i].RoleName < roles[j].RoleName
|
|
})
|
|
|
|
start := 0
|
|
if input.Marker != "" {
|
|
start = len(roles)
|
|
for i, role := range roles {
|
|
if role.RoleName == input.Marker {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
roles = roles[start:]
|
|
|
|
limit := len(roles)
|
|
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
|
limit = int(input.MaxItems)
|
|
}
|
|
|
|
out := &ListRolesOutput{
|
|
Roles: make([]types.Role, limit),
|
|
}
|
|
copy(out.Roles, roles[:limit])
|
|
if limit < len(roles) {
|
|
out.IsTruncated = true
|
|
out.Marker = out.Roles[limit-1].RoleName
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// DeleteRole is DeleteUser's counterpart for roles - see its doc comment for
|
|
// why the dependency check (no inline policies) is confirmed via a same-data
|
|
// CAS write (replaceRole) immediately before the actual delete, instead of
|
|
// an unconditional delete straight after the check.
|
|
func (s *VaultStore) DeleteRole(ctx context.Context, roleName string) error {
|
|
for range maxCASRetries {
|
|
role, version, err := s.readRoleVersion(roleName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(role.Policies.Inline) > 0 {
|
|
return iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)
|
|
}
|
|
|
|
if _, err := s.replaceRole(ctx, *role, version); err != nil {
|
|
if errors.Is(err, errConcurrentModification) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
return s.deleteRoleByPath(role.RoleName)
|
|
}
|
|
return iamerr.ConcurrentModification()
|
|
}
|
|
|
|
func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error) {
|
|
return s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error {
|
|
role.AssumeRolePolicyDocument = input.PolicyDocument
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *VaultStore) PutRolePolicy(ctx context.Context, input PutRolePolicyInput) error {
|
|
_, err := s.withRoleCAS(ctx, input.RoleName, func(role *types.Role) error {
|
|
newTotal := len(input.PolicyDocument)
|
|
replaceAt := -1
|
|
for i, p := range role.Policies.Inline {
|
|
if p.PolicyName == input.PolicyName {
|
|
replaceAt = i
|
|
continue
|
|
}
|
|
newTotal += len(p.PolicyDocument)
|
|
}
|
|
if newTotal > MaxInlinePolicyBytesPerRole {
|
|
return iamerr.InlinePolicyQuotaExceeded("role", input.RoleName, MaxInlinePolicyBytesPerRole)
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
if replaceAt >= 0 {
|
|
role.Policies.Inline[replaceAt].PolicyDocument = input.PolicyDocument
|
|
role.Policies.Inline[replaceAt].UpdateDate = now
|
|
} else {
|
|
role.Policies.Inline = append(role.Policies.Inline, types.PolicyEntry{
|
|
PolicyName: input.PolicyName,
|
|
PolicyDocument: input.PolicyDocument,
|
|
CreateDate: now,
|
|
UpdateDate: now,
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) GetRolePolicy(ctx context.Context, roleName, policyName string) (*types.PolicyEntry, error) {
|
|
role, err := s.GetRole(ctx, roleName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, p := range role.Policies.Inline {
|
|
if p.PolicyName == policyName {
|
|
cloned := p
|
|
return &cloned, nil
|
|
}
|
|
}
|
|
|
|
return nil, iamerr.NoSuchEntityRolePolicy(roleName, policyName)
|
|
}
|
|
|
|
func (s *VaultStore) DeleteRolePolicy(ctx context.Context, roleName, policyName string) error {
|
|
_, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error {
|
|
idx := -1
|
|
for i, p := range role.Policies.Inline {
|
|
if p.PolicyName == policyName {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx == -1 {
|
|
return iamerr.NoSuchEntityRolePolicy(roleName, policyName)
|
|
}
|
|
role.Policies.Inline = slices.Delete(role.Policies.Inline, idx, idx+1)
|
|
return nil
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) ListRolePolicies(ctx context.Context, input ListRolePoliciesInput) (*ListRolePoliciesOutput, error) {
|
|
role, err := s.GetRole(ctx, input.RoleName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
names := make([]string, 0, len(role.Policies.Inline))
|
|
for _, p := range role.Policies.Inline {
|
|
names = append(names, p.PolicyName)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
start := 0
|
|
if input.Marker != "" {
|
|
start = len(names)
|
|
for i, name := range names {
|
|
if name == input.Marker {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
names = names[start:]
|
|
|
|
limit := len(names)
|
|
if input.MaxItems > 0 && int(input.MaxItems) < limit {
|
|
limit = int(input.MaxItems)
|
|
}
|
|
|
|
out := &ListRolePoliciesOutput{
|
|
PolicyNames: make([]string, limit),
|
|
}
|
|
copy(out.PolicyNames, names[:limit])
|
|
if limit < len(names) {
|
|
out.IsTruncated = true
|
|
out.Marker = out.PolicyNames[limit-1]
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// replaceRole overwrites the stored document for role.RoleName using a
|
|
// version-checked (CAS) write tied to readVersion, instead of an
|
|
// unconditional delete-then-recreate — see replaceUser for the rationale.
|
|
func (s *VaultStore) replaceRole(ctx context.Context, role types.Role, readVersion int32) (*types.Role, error) {
|
|
roleMap, err := roleToVaultMap(role)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize role: %w", err)
|
|
}
|
|
|
|
key := caseFoldKey(role.RoleName)
|
|
path := s.rolesPath() + "/" + key
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{key: roleMap},
|
|
Options: map[string]any{"cas": readVersion},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, errConcurrentModification
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, errConcurrentModification
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
return cloneRole(role), nil
|
|
}
|
|
|
|
// withRoleCAS is withUserCAS's counterpart for roles.
|
|
func (s *VaultStore) withRoleCAS(ctx context.Context, roleName string, mutate func(*types.Role) error) (*types.Role, error) {
|
|
for range maxCASRetries {
|
|
role, version, err := s.readRoleVersion(roleName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := mutate(role); err != nil {
|
|
return nil, err
|
|
}
|
|
result, err := s.replaceRole(ctx, *role, version)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
if !errors.Is(err, errConcurrentModification) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, iamerr.ConcurrentModification()
|
|
}
|
|
|
|
// deleteRoleByPath permanently removes a role secret and all its versions
|
|
// without checking for existence first.
|
|
func (s *VaultStore) deleteRoleByPath(roleName string) error {
|
|
path := s.rolesPath() + "/" + caseFoldKey(roleName)
|
|
_, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var errInvalidVaultRole = errors.New("invalid role entry in vault secrets engine")
|
|
|
|
// roleToVaultMap is userToVaultMap's counterpart for roles.
|
|
func roleToVaultMap(role types.Role) (map[string]any, error) {
|
|
b, err := json.Marshal(role)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// parseVaultRole reconstructs a Role from the raw map[string]any that vault
|
|
// returns. The outer key is the role name.
|
|
func parseVaultRole(data map[string]any, roleName string) (types.Role, error) {
|
|
raw, ok := data[roleName]
|
|
if !ok {
|
|
return types.Role{}, errInvalidVaultRole
|
|
}
|
|
roleMap, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return types.Role{}, errInvalidVaultRole
|
|
}
|
|
b, err := json.Marshal(roleMap)
|
|
if err != nil {
|
|
return types.Role{}, fmt.Errorf("re-marshal vault role: %w", err)
|
|
}
|
|
var role types.Role
|
|
if err := json.Unmarshal(b, &role); err != nil {
|
|
return types.Role{}, fmt.Errorf("unmarshal vault role: %w", err)
|
|
}
|
|
return role, nil
|
|
}
|
|
|
|
// oidcProvidersPath is the KV prefix under which OIDC providers are stored,
|
|
// kept distinct from secretStoragePath/rolesPath.
|
|
func (s *VaultStore) oidcProvidersPath() string {
|
|
return s.secretStoragePath + "/oidc-providers"
|
|
}
|
|
|
|
// oidcProviderPathSegment returns the literal KV path segment for a
|
|
// provider identified by its scheme-stripped url. OIDC provider URLs may
|
|
// themselves contain "/" (e.g. "host/" and "host/path" are distinct valid
|
|
// providers) and Vault KV paths treat "/" as a path
|
|
// separator, so — unlike RoleName/UserName, which never contain "/" and are
|
|
// used as literal path segments directly — the raw url cannot safely be
|
|
// used as a KV path segment. base64url-encoding (RawURLEncoding: lossless,
|
|
// produces only [A-Za-z0-9_-], no "/" or "=" padding) collapses it to one
|
|
// opaque, path-safe segment. The same segment is reused as the single outer
|
|
// JSON key inside the KV secret body (a deliberate deviation from
|
|
// roleToVaultMap/userToVaultMap's convention of keying on the
|
|
// human-readable name — simpler here since only one identifier needs to be
|
|
// tracked for read-back, not two).
|
|
func oidcProviderPathSegment(url string) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(url))
|
|
}
|
|
|
|
func (s *VaultStore) CreateOIDCProvider(_ context.Context, provider types.OIDCProvider) (*types.OIDCProvider, error) {
|
|
segment := oidcProviderPathSegment(provider.Url)
|
|
path := s.oidcProvidersPath() + "/" + segment
|
|
displayURL := "https://" + provider.Url
|
|
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...)
|
|
if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...)
|
|
if err != nil && !vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
if resp != nil {
|
|
if slices.Contains(resp.Data.Keys, segment) {
|
|
return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL)
|
|
}
|
|
if len(resp.Data.Keys) >= MaxOIDCProvidersPerAccount {
|
|
return nil, iamerr.OIDCProvidersPerAccountLimitExceeded(MaxOIDCProvidersPerAccount)
|
|
}
|
|
}
|
|
|
|
providerMap, err := oidcProviderToVaultMap(provider)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize oidc provider: %w", err)
|
|
}
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{segment: providerMap},
|
|
Options: map[string]any{"cas": 0},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return nil, iamerr.EntityAlreadyExistsOIDCProvider(displayURL)
|
|
}
|
|
if vault.IsErrorStatus(err, http.StatusForbidden) {
|
|
return nil, fmt.Errorf("vault 403 permission denied on path %q. check KV mount path and policy. original: %w", path, err)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
return cloneOIDCProvider(provider), nil
|
|
}
|
|
|
|
func (s *VaultStore) GetOIDCProvider(_ context.Context, arn string) (*types.OIDCProvider, error) {
|
|
provider, _, err := s.readOIDCProviderVersion(arn)
|
|
return provider, err
|
|
}
|
|
|
|
// readOIDCProviderVersion is GetOIDCProvider's counterpart to
|
|
// readUserVersion/readRoleVersion: it additionally returns the KV version
|
|
// the record was read at, so a mutation can write back with a matching CAS
|
|
// value instead of racing on a blind delete-then-recreate (see
|
|
// replaceOIDCProvider).
|
|
func (s *VaultStore) readOIDCProviderVersion(arn string) (*types.OIDCProvider, int32, error) {
|
|
url, err := iamutil.ParseOIDCProviderArn(arn)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
segment := oidcProviderPathSegment(url)
|
|
path := s.oidcProvidersPath() + "/" + segment
|
|
|
|
resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, 0, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return nil, 0, iamerr.NoSuchEntityOIDCProviderGet(arn)
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
}
|
|
|
|
provider, err := parseVaultOIDCProvider(resp.Data.Data, segment)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return cloneOIDCProvider(provider), kvVersion(resp.Data.Metadata), nil
|
|
}
|
|
|
|
func (s *VaultStore) ListOIDCProviders(_ context.Context) (*ListOIDCProvidersOutput, error) {
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.oidcProvidersPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return &ListOIDCProvidersOutput{Providers: []types.OpenIDConnectProviderListEntry{}}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
entries := make([]types.OpenIDConnectProviderListEntry, 0, len(resp.Data.Keys))
|
|
for _, segment := range resp.Data.Keys {
|
|
// Read each secret by its already-known key rather than decoding
|
|
// segment back to a url, populating the list from each secret's own
|
|
// stored fields.
|
|
path := s.oidcProvidersPath() + "/" + segment
|
|
secretResp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
secretResp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
provider, err := parseVaultOIDCProvider(secretResp.Data.Data, segment)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries = append(entries, types.OpenIDConnectProviderListEntry{Arn: provider.Arn})
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Arn < entries[j].Arn })
|
|
return &ListOIDCProvidersOutput{Providers: entries}, nil
|
|
}
|
|
|
|
func (s *VaultStore) DeleteOIDCProvider(_ context.Context, arn string) error {
|
|
url, err := iamutil.ParseOIDCProviderArn(arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url)
|
|
|
|
// Existence check first: unlike deleteRoleByPath (only reached after
|
|
// DeleteRole's own prior GetRole existence check), Delete's own
|
|
// not-found path is load-bearing here (NOT idempotent).
|
|
if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return iamerr.NoSuchEntityOIDCProviderDelete(arn)
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
if _, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...); err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return iamerr.NoSuchEntityOIDCProviderDelete(arn)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
return s.deleteOIDCProviderByURL(url)
|
|
}
|
|
|
|
func (s *VaultStore) deleteOIDCProviderByURL(url string) error {
|
|
path := s.oidcProvidersPath() + "/" + oidcProviderPathSegment(url)
|
|
_, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2DeleteMetadataAndAllVersions(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *VaultStore) AddClientIDToOIDCProvider(ctx context.Context, arn, clientID string) error {
|
|
return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error {
|
|
if slices.Contains(provider.ClientIDList, clientID) {
|
|
return nil
|
|
}
|
|
if len(provider.ClientIDList) >= MaxClientIDsPerOIDCProvider {
|
|
return iamerr.ClientIdsPerOpenIdConnectProviderLimitExceeded(MaxClientIDsPerOIDCProvider)
|
|
}
|
|
provider.ClientIDList = append(provider.ClientIDList, clientID)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *VaultStore) RemoveClientIDFromOIDCProvider(ctx context.Context, arn, clientID string) error {
|
|
return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error {
|
|
idx := slices.Index(provider.ClientIDList, clientID)
|
|
if idx == -1 {
|
|
return nil
|
|
}
|
|
provider.ClientIDList = slices.Delete(provider.ClientIDList, idx, idx+1)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *VaultStore) UpdateOIDCProviderThumbprint(ctx context.Context, arn string, thumbprints []string) error {
|
|
return s.withOIDCProviderCAS(ctx, arn, func(provider *types.OIDCProvider) error {
|
|
provider.ThumbprintList = thumbprints
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// replaceOIDCProvider overwrites the stored document for provider.Url using
|
|
// a version-checked (CAS) write tied to readVersion, instead of an
|
|
// unconditional delete-then-recreate — see replaceUser for the rationale.
|
|
func (s *VaultStore) replaceOIDCProvider(ctx context.Context, provider types.OIDCProvider, readVersion int32) error {
|
|
segment := oidcProviderPathSegment(provider.Url)
|
|
path := s.oidcProvidersPath() + "/" + segment
|
|
|
|
providerMap, err := oidcProviderToVaultMap(provider)
|
|
if err != nil {
|
|
return fmt.Errorf("serialize oidc provider: %w", err)
|
|
}
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{segment: providerMap},
|
|
Options: map[string]any{"cas": readVersion},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return errConcurrentModification
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2Write(ctx, path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "check-and-set") {
|
|
return errConcurrentModification
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// withOIDCProviderCAS is withUserCAS's counterpart for OIDC providers.
|
|
func (s *VaultStore) withOIDCProviderCAS(ctx context.Context, arn string, mutate func(*types.OIDCProvider) error) error {
|
|
for range maxCASRetries {
|
|
provider, version, err := s.readOIDCProviderVersion(arn)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := mutate(provider); err != nil {
|
|
return err
|
|
}
|
|
err = s.replaceOIDCProvider(ctx, *provider, version)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, errConcurrentModification) {
|
|
return err
|
|
}
|
|
}
|
|
return iamerr.ConcurrentModification()
|
|
}
|
|
|
|
var errInvalidVaultOIDCProvider = errors.New("invalid oidc provider entry in vault secrets engine")
|
|
|
|
func oidcProviderToVaultMap(provider types.OIDCProvider) (map[string]any, error) {
|
|
b, err := json.Marshal(provider)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// parseVaultOIDCProvider reconstructs an OIDCProvider from the raw
|
|
// map[string]any vault returns. The outer key is the base64url path
|
|
// segment used at write time (oidcProviderPathSegment), not a
|
|
// human-readable value — unlike parseVaultRole/parseVaultUser.
|
|
func parseVaultOIDCProvider(data map[string]any, segment string) (types.OIDCProvider, error) {
|
|
raw, ok := data[segment]
|
|
if !ok {
|
|
return types.OIDCProvider{}, errInvalidVaultOIDCProvider
|
|
}
|
|
providerMap, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return types.OIDCProvider{}, errInvalidVaultOIDCProvider
|
|
}
|
|
b, err := json.Marshal(providerMap)
|
|
if err != nil {
|
|
return types.OIDCProvider{}, fmt.Errorf("re-marshal vault oidc provider: %w", err)
|
|
}
|
|
var provider types.OIDCProvider
|
|
if err := json.Unmarshal(b, &provider); err != nil {
|
|
return types.OIDCProvider{}, fmt.Errorf("unmarshal vault oidc provider: %w", err)
|
|
}
|
|
return provider, nil
|
|
}
|
|
|
|
// sessionsPath is the KV prefix under which AssumeRoleWithWebIdentity
|
|
// sessions are stored, kept distinct from secretStoragePath/rolesPath/
|
|
// oidcProvidersPath.
|
|
func (s *VaultStore) sessionsPath() string {
|
|
return s.secretStoragePath + "/sessions"
|
|
}
|
|
|
|
func (s *VaultStore) CreateSession(ctx context.Context, session types.Session) (*types.Session, error) {
|
|
// Bound how many concurrently-active sessions a single role can
|
|
// accumulate — without this, one valid federated token replayed against
|
|
// AssumeRoleWithWebIdentity indefinitely grows the number of KV paths
|
|
// and metadata records this backend has to carry for that role.
|
|
count, err := s.activeSessionCountForRole(ctx, session.RoleArn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count >= MaxActiveSessionsPerRole {
|
|
return nil, iamerr.GetAPIError(iamerr.ErrThrottling)
|
|
}
|
|
|
|
path := s.sessionsPath() + "/" + session.AccessKeyId
|
|
|
|
// Pin the secret's own TTL to the session's expiration via Vault's
|
|
// native KV v2 delete_version_after metadata, so an expired session is
|
|
// eventually purged from storage by Vault itself even if GetSession is
|
|
// never called again for it (e.g. a session minted once and never
|
|
// reused) — GetSession's own expired-session delete only reclaims
|
|
// storage for sessions someone actually looks up again.
|
|
//
|
|
// This must happen *before* the version below is written: Vault
|
|
// computes a version's deletion_time from whatever delete_version_after
|
|
// is in effect at the moment that version is written, not retroactively
|
|
// — setting it afterward leaves an already-written version with no
|
|
// deletion_time at all (confirmed against a live Vault server: a
|
|
// version written before delete_version_after was set was never
|
|
// scheduled for deletion, while one written after was). Best-effort: a
|
|
// failure here still leaves a fully functional (if not self-cleaning)
|
|
// session, so it's logged rather than failing the create.
|
|
if err := s.setSessionTTL(path, session.Expiration); err != nil {
|
|
debuglogger.Logf("failed to set Vault session TTL metadata for access key %q: %v", session.AccessKeyId, err)
|
|
}
|
|
|
|
sessionMap, err := sessionToVaultMap(session)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize session: %w", err)
|
|
}
|
|
req := schema.KvV2WriteRequest{
|
|
Data: map[string]any{session.AccessKeyId: sessionMap},
|
|
Options: map[string]any{"cas": 0},
|
|
}
|
|
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2Write(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
cloned := session
|
|
return &cloned, nil
|
|
}
|
|
|
|
// activeSessionCountForRole counts this backend's currently-active sessions
|
|
// belonging to roleArn, so CreateSession can enforce
|
|
// MaxActiveSessionsPerRole. GetSession is reused to read each candidate
|
|
// entry: it already purges an expired-but-not-yet-Vault-reaped session on
|
|
// read, so an expired session is neither counted nor left to inflate a
|
|
// future count.
|
|
func (s *VaultStore) activeSessionCountForRole(ctx context.Context, roleArn string) (int, error) {
|
|
resp, err := s.client.Secrets.KvV2List(context.Background(), s.sessionsPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return 0, nil
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return 0, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2List(context.Background(), s.sessionsPath(), s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
return 0, nil
|
|
}
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
count := 0
|
|
for _, key := range resp.Data.Keys {
|
|
session, err := s.GetSession(ctx, key)
|
|
if err != nil {
|
|
if errors.Is(err, ErrSessionNotFound) {
|
|
continue
|
|
}
|
|
return 0, err
|
|
}
|
|
if session.RoleArn == roleArn {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// setSessionTTL sets path's KV v2 delete_version_after metadata to the
|
|
// duration remaining until expiration, so Vault purges the version itself
|
|
// once it's expired.
|
|
func (s *VaultStore) setSessionTTL(path string, expiration time.Time) error {
|
|
ttl := time.Until(expiration)
|
|
if ttl <= 0 {
|
|
ttl = time.Second
|
|
}
|
|
|
|
req := schema.KvV2WriteMetadataRequest{DeleteVersionAfter: fmt.Sprintf("%.0fs", ttl.Seconds())}
|
|
_, err := s.client.Secrets.KvV2WriteMetadata(context.Background(), path, req, s.kvReqOpts...)
|
|
if err != nil {
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return reauthErr
|
|
}
|
|
_, err = s.client.Secrets.KvV2WriteMetadata(context.Background(), path, req, s.kvReqOpts...)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *VaultStore) GetSession(_ context.Context, accessKeyID string) (*types.Session, error) {
|
|
path := s.sessionsPath() + "/" + accessKeyID
|
|
|
|
resp, err := s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
// Either this access key never existed, or Vault's own
|
|
// delete_version_after TTL (see setSessionTTL) already
|
|
// soft-deleted the version — confirmed live: Vault answers a
|
|
// read for a soft-deleted-but-not-yet-destroyed version with
|
|
// 404, not 200-with-null-data. Either way, best-effort purge
|
|
// the lingering metadata record now, since Vault doesn't
|
|
// appear to reclaim it on its own once merely soft-deleted.
|
|
s.purgeSession(accessKeyID)
|
|
return nil, ErrSessionNotFound
|
|
}
|
|
if reauthErr := s.reAuthIfNeeded(err); reauthErr != nil {
|
|
return nil, reauthErr
|
|
}
|
|
resp, err = s.client.Secrets.KvV2Read(context.Background(), path, s.kvReqOpts...)
|
|
if err != nil {
|
|
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
|
s.purgeSession(accessKeyID)
|
|
return nil, ErrSessionNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
session, err := parseVaultSession(resp.Data.Data, accessKeyID)
|
|
if err == nil && session.Expiration.After(time.Now().UTC()) {
|
|
cloned := session
|
|
return &cloned, nil
|
|
}
|
|
|
|
// Readable but our own Expiration field says it's past due anyway
|
|
// (should be rare/racy, since setSessionTTL pins Vault's own TTL to
|
|
// this same value) — purge now rather than waiting on Vault.
|
|
s.purgeSession(accessKeyID)
|
|
return nil, ErrSessionNotFound
|
|
}
|
|
|
|
// purgeSession permanently deletes accessKeyID's session metadata and
|
|
// version record. Best-effort: a failure just leaves the (already
|
|
// not-found-to-the-caller) entry lingering until some later call retries
|
|
// the purge or Vault's own cleanup eventually catches it.
|
|
func (s *VaultStore) purgeSession(accessKeyID string) {
|
|
if err := s.deleteByPath("sessions/" + accessKeyID); err != nil {
|
|
debuglogger.Logf("failed to delete expired Vault session for access key %q: %v", accessKeyID, err)
|
|
}
|
|
}
|
|
|
|
var errInvalidVaultSession = errors.New("invalid session entry in vault secrets engine")
|
|
|
|
func sessionToVaultMap(session types.Session) (map[string]any, error) {
|
|
b, err := json.Marshal(session)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// parseVaultSession reconstructs a Session from the raw map[string]any
|
|
// vault returns. The outer key is the AccessKeyId.
|
|
func parseVaultSession(data map[string]any, accessKeyID string) (types.Session, error) {
|
|
raw, ok := data[accessKeyID]
|
|
if !ok {
|
|
return types.Session{}, errInvalidVaultSession
|
|
}
|
|
sessionMap, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return types.Session{}, errInvalidVaultSession
|
|
}
|
|
b, err := json.Marshal(sessionMap)
|
|
if err != nil {
|
|
return types.Session{}, fmt.Errorf("re-marshal vault session: %w", err)
|
|
}
|
|
var session types.Session
|
|
if err := json.Unmarshal(b, &session); err != nil {
|
|
return types.Session{}, fmt.Errorf("unmarshal vault session: %w", err)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
var errInvalidVaultUser = errors.New("invalid user entry in vault secrets engine")
|
|
|
|
// userToVaultMap round-trips User through JSON to produce a map[string]any
|
|
// that vault can store without losing type information on read-back.
|
|
func userToVaultMap(user types.User) (map[string]any, error) {
|
|
b, err := json.Marshal(user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// parseVaultUser reconstructs a User from the raw map[string]any that vault
|
|
// returns. The outer key is the username.
|
|
func parseVaultUser(data map[string]any, username string) (types.User, error) {
|
|
raw, ok := data[username]
|
|
if !ok {
|
|
return types.User{}, errInvalidVaultUser
|
|
}
|
|
userMap, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return types.User{}, errInvalidVaultUser
|
|
}
|
|
b, err := json.Marshal(userMap)
|
|
if err != nil {
|
|
return types.User{}, fmt.Errorf("re-marshal vault user: %w", err)
|
|
}
|
|
var user types.User
|
|
if err := json.Unmarshal(b, &user); err != nil {
|
|
return types.User{}, fmt.Errorf("unmarshal vault user: %w", err)
|
|
}
|
|
return user, nil
|
|
}
|