From f8552bb754212144620584fc5a8dfdf2bd3935fd Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Wed, 6 Aug 2025 06:58:50 -0500 Subject: [PATCH 01/13] initial rotate manager implementation --- backend.go | 46 ++++++++++++ backend_test.go | 23 ++++-- client.go | 75 ++++++++++++++++++++ path_config.go | 168 +++++++++++++++++++++++++++++++++++++++----- path_config_test.go | 56 ++++++++++++++- 5 files changed, 345 insertions(+), 23 deletions(-) diff --git a/backend.go b/backend.go index 2fc0a15..1260190 100644 --- a/backend.go +++ b/backend.go @@ -5,6 +5,7 @@ package tfc import ( "context" + "fmt" "strings" "sync" @@ -57,6 +58,10 @@ func backend() *tfBackend { }, BackendType: logical.TypeLogical, Invalidate: b.invalidate, + RotateCredential: func(ctx context.Context, req *logical.Request) error { + _, err := b.rotateRoot(ctx, req) + return err + }, } return &b @@ -106,6 +111,47 @@ func (b *tfBackend) getClient(ctx context.Context, s logical.Storage) (*client, return b.client, nil } +func (b *tfBackend) rotateRoot(ctx context.Context, req *logical.Request) (*logical.Response, error) { + config, err := getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + + if config.Token == "" { + return logical.ErrorResponse("backend is missing token"), nil + } + + if config.TokenType == "" || config.ID == "" { + return logical.ErrorResponse("token_type and id must be configured for token rotation"), nil + } + + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return nil, fmt.Errorf("error getting client: %w", err) + } + + currentToken := config.Token + token, err := client.RotateRootToken(ctx, config.TokenType, config.ID, config.OldToken, currentToken) + if err != nil { + return nil, fmt.Errorf("error rotating root token: %w", err) + } + + config.Token = token + + entry, err := logical.StorageEntryJSON(configStoragePath, config) + if err != nil { + return nil, err + } + + if err := req.Storage.Put(ctx, entry); err != nil { + return nil, err + } + + b.reset() + + return nil, nil +} + const backendHelp = ` The Terraform Cloud secrets backend dynamically generates organization and user tokens. diff --git a/backend_test.go b/backend_test.go index 1312866..baccc10 100644 --- a/backend_test.go +++ b/backend_test.go @@ -20,6 +20,9 @@ const ( envVarTerraformTeamID = "TF_TEAM_ID" envVarTerraformUserID = "TF_USER_ID" envVarTerraformAddress = "TF_ADDRESS" + // Rotation environment variables + envVarTerraformTokenType = "TF_TOKEN_TYPE" + envVarTerraformID = "TF_ID" ) func getTestBackend(tb testing.TB) (*tfBackend, logical.Storage) { @@ -59,13 +62,23 @@ type testEnv struct { } func (e *testEnv) AddConfig(t *testing.T) { + data := map[string]interface{}{ + "token": e.Token, + } + + // Add rotation parameters if environment variables are set + if tokenType := os.Getenv(envVarTerraformTokenType); tokenType != "" { + data["token_type"] = tokenType + } + if id := os.Getenv(envVarTerraformID); id != "" { + data["id"] = id + } + req := &logical.Request{ Operation: logical.CreateOperation, Path: "config", Storage: e.Storage, - Data: map[string]interface{}{ - "token": e.Token, - }, + Data: data, } resp, err := e.Backend.HandleRequest(e.Context, req) require.Nil(t, resp) @@ -119,8 +132,8 @@ func (e *testEnv) AddTeamLegacyTokenRole(t *testing.T) { Path: "role/test-team-token", Storage: e.Storage, Data: map[string]interface{}{ - "organization": e.Organization, - "team_id": e.TeamID, + "team_id": e.TeamID, + "credential_type": teamLegacyCredentialType, }, } resp, err := e.Backend.HandleRequest(e.Context, req) diff --git a/client.go b/client.go index ee344d2..ac7f739 100644 --- a/client.go +++ b/client.go @@ -4,7 +4,9 @@ package tfc import ( + "context" "errors" + "fmt" "time" "github.com/hashicorp/go-tfe" @@ -41,3 +43,76 @@ func newClient(config *tfConfig) (*client, error) { tfc, }, nil } + +// RotateRootToken rotates the root token by creating a new token based on the +// token type and ID configured in the tfConfig. +func (c *client) RotateRootToken(ctx context.Context, tokenType, id, oldToken, currentToken string) (string, error) { + if tokenType == "" || id == "" { + return "", errors.New("token_type and id must be specified for token rotation") + } + + var newToken string + var err error + + switch tokenType { + case "organization": + newToken, err = c.rotateOrganizationToken(ctx, id) + case "team": + newToken, err = c.rotateTeamToken(ctx, id) + case "user": + newToken, err = c.rotateUserToken(ctx, id) + default: + return "", fmt.Errorf("unsupported token_type: %s", tokenType) + } + + if err != nil { + return "", err + } + + if oldToken == "delete" && (tokenType == "team" || tokenType == "user") { + if err := c.deleteToken(ctx, currentToken, tokenType); err != nil { + return "", fmt.Errorf("failed to delete old token: %w", err) + } + } + + return newToken, nil +} + +func (c *client) deleteToken(ctx context.Context, token, tokenType string) error { + // Organization tokens are handled differently, so we only need to handle team and user tokens here + if tokenType == "team" { + return c.TeamTokens.Delete(ctx, token) + } else if tokenType == "user" { + return c.UserTokens.Delete(ctx, token) + } + return nil +} + +func (c *client) rotateOrganizationToken(ctx context.Context, orgName string) (string, error) { + // Generate a new organization token + newToken, err := c.OrganizationTokens.Create(ctx, orgName) + if err != nil { + return "", fmt.Errorf("failed to generate new organization token: %w", err) + } + return newToken.Token, nil +} + +func (c *client) rotateTeamToken(ctx context.Context, teamID string) (string, error) { + // Generate a new team token + newToken, err := c.TeamTokens.Create(ctx, teamID) + if err != nil { + return "", fmt.Errorf("failed to generate new team token: %w", err) + } + return newToken.Token, nil +} + +func (c *client) rotateUserToken(ctx context.Context, userID string) (string, error) { + // Create a new user token + newToken, err := c.UserTokens.Create(ctx, userID, tfe.UserTokenCreateOptions{ + Description: "Rotated by Vault", + }) + if err != nil { + return "", fmt.Errorf("failed to create new user token: %w", err) + } + return newToken.Token, nil +} diff --git a/path_config.go b/path_config.go index 061db62..9fd3ff3 100644 --- a/path_config.go +++ b/path_config.go @@ -9,7 +9,9 @@ import ( "fmt" "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/automatedrotationutil" "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/sdk/rotation" ) const ( @@ -17,13 +19,18 @@ const ( ) type tfConfig struct { - Token string `json:"token"` - Address string `json:"address"` - BasePath string `json:"base_path"` + automatedrotationutil.AutomatedRotationParams + + Token string `json:"token"` + TokenType string `json:"token_type,omitempty"` + ID string `json:"id,omitempty"` + OldToken string `json:"old_token,omitempty"` + Address string `json:"address"` + BasePath string `json:"base_path"` } func pathConfig(b *tfBackend) *framework.Path { - return &framework.Path{ + p := &framework.Path{ Pattern: "config", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: operationPrefixTerraformCloud, @@ -38,6 +45,19 @@ func pathConfig(b *tfBackend) *framework.Path { Sensitive: true, }, }, + "token_type": { + Type: framework.TypeString, + Description: "The type of token (organization, team, user). Required for rotation.", + }, + "id": { + Type: framework.TypeString, + Description: "The ID of the organization, team, or user associated with the token. Required for rotation when token_type is specified.", + }, + "old_token": { + Type: framework.TypeString, + Description: "The behavior for handling the old token. Can be 'delete' or 'keep'. Defaults to 'delete'.", + Default: "delete", + }, "address": { Type: framework.TypeString, Description: `The address to access Terraform Cloud or Enterprise. @@ -81,6 +101,11 @@ func pathConfig(b *tfBackend) *framework.Path { HelpSynopsis: pathConfigHelpSynopsis, HelpDescription: pathConfigHelpDescription, } + + // Add automated rotation fields + automatedrotationutil.AddAutomatedRotationFields(p.Fields) + + return p } func (b *tfBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) { @@ -98,11 +123,19 @@ func (b *tfBackend) pathConfigRead(ctx context.Context, req *logical.Request, da return nil, err } + if config == nil { + return nil, nil + } + + configData := map[string]interface{}{ + "address": config.Address, + "base_path": config.BasePath, + } + + config.PopulateAutomatedRotationData(configData) + return &logical.Response{ - Data: map[string]interface{}{ - "address": config.Address, - "base_path": config.BasePath, - }, + Data: configData, }, nil } @@ -119,24 +152,112 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d config = new(tfConfig) } - address := data.Get("address").(string) - basePath := data.Get("base_path").(string) + if address, ok := data.GetOk("address"); ok { + config.Address = address.(string) + } else if req.Operation == logical.CreateOperation { + config.Address = data.Get("address").(string) + } - config.Address = address - config.BasePath = basePath + if basePath, ok := data.GetOk("base_path"); ok { + config.BasePath = basePath.(string) + } else if req.Operation == logical.CreateOperation { + config.BasePath = data.Get("base_path").(string) + } - token, ok := data.GetOk("token") - if ok { + if token, ok := data.GetOk("token"); ok { config.Token = token.(string) + } else if req.Operation == logical.CreateOperation { + config.Token = data.Get("token").(string) + } + + if tokenType, ok := data.GetOk("token_type"); ok { + config.TokenType = tokenType.(string) + } else if req.Operation == logical.CreateOperation { + config.TokenType = data.Get("token_type").(string) + } + + if id, ok := data.GetOk("id"); ok { + config.ID = id.(string) + } else if req.Operation == logical.CreateOperation { + config.ID = data.Get("id").(string) } + // Validate token_type and id fields for rotation + if config.TokenType != "" { + if config.TokenType != "organization" && config.TokenType != "team" && config.TokenType != "user" { + return logical.ErrorResponse("invalid token_type: must be 'organization', 'team', or 'user'"), nil + } + if config.ID == "" { + return logical.ErrorResponse("id is required when token_type is specified"), nil + } + } + + if oldToken, ok := data.GetOk("old_token"); ok { + config.OldToken = oldToken.(string) + if config.OldToken != "delete" && config.OldToken != "keep" { + return logical.ErrorResponse("invalid old_token: must be 'delete' or 'keep'"), nil + } + } else if req.Operation == logical.CreateOperation { + config.OldToken = data.Get("old_token").(string) + } + + // Parse automated rotation fields + if err := config.ParseAutomatedRotationFields(data); err != nil { + return logical.ErrorResponse(err.Error()), nil + } + + var performedRotationManagerOperation string + if config.ShouldDeregisterRotationJob() { + performedRotationManagerOperation = rotation.PerformedDeregistration + // Disable Automated Rotation and Deregister credentials if required + deregisterReq := &rotation.RotationJobDeregisterRequest{ + MountPoint: req.MountPoint, + ReqPath: req.Path, + } + + b.Logger().Debug("Deregistering rotation job", "mount", req.MountPoint+req.Path) + if err := b.System().DeregisterRotationJob(ctx, deregisterReq); err != nil { + return logical.ErrorResponse("error deregistering rotation job: %s", err), nil + } + } else if config.ShouldRegisterRotationJob() { + performedRotationManagerOperation = rotation.PerformedRegistration + // Register the rotation job if it's required. + cfgReq := &rotation.RotationJobConfigureRequest{ + MountPoint: req.MountPoint, + ReqPath: req.Path, + RotationSchedule: config.RotationSchedule, + RotationWindow: config.RotationWindow, + RotationPeriod: config.RotationPeriod, + } + + b.Logger().Debug("Registering rotation job", "mount", req.MountPoint+req.Path) + if _, err = b.System().RegisterRotationJob(ctx, cfgReq); err != nil { + return logical.ErrorResponse("error registering rotation job: %s", err), nil + } + } + + // Save the config entry, err := logical.StorageEntryJSON(configStoragePath, config) if err != nil { - return nil, err + wrappedError := err + if performedRotationManagerOperation != "" { + b.Logger().Error("write to storage failed but the rotation manager still succeeded.", + "operation", performedRotationManagerOperation, "mount", req.MountPoint, "path", req.Path) + wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+ + "operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOperation, req.MountPoint, req.Path, err) + } + return nil, wrappedError } if err := req.Storage.Put(ctx, entry); err != nil { - return nil, err + wrappedError := err + if performedRotationManagerOperation != "" { + b.Logger().Error("write to storage failed but the rotation manager still succeeded.", + "operation", performedRotationManagerOperation, "mount", req.MountPoint, "path", req.Path) + wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+ + "operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOperation, req.MountPoint, req.Path, err) + } + return nil, wrappedError } // reset the client so the next invocation will pick up the new configuration @@ -186,4 +307,19 @@ to allow Vault to create tokens. If you are running Terraform Enterprise, you can specify the address and base path for your instance and API endpoint. + +Automatic token rotation (requires Vault Enterprise): +For automatic token rotation, specify: +- token_type: The type of token (organization, team, user) +- id: The ID of the organization, team, or user associated with the token +- old_token: How to handle the old token ("delete" or "keep", defaults to "delete") +- rotation_period or rotation_schedule: When to rotate the token + +Example with rotation: +vault write terraform/config \ + token="your-token" \ + token_type="team" \ + id="team-123" \ + old_token="delete" \ + rotation_period="24h" ` diff --git a/path_config_test.go b/path_config_test.go index 9c47438..9eefe77 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -6,7 +6,9 @@ package tfc import ( "context" "fmt" + "os" "testing" + "time" "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/require" @@ -129,11 +131,61 @@ func testConfigRead(t *testing.T, b logical.Backend, s logical.Storage, expected actualV, ok := resp.Data[k] if !ok { - return fmt.Errorf(`expected data["%s"] = %v but was not included in read output"`, k, expectedV) + return fmt.Errorf(`expected data["%s"] = %v but was not included in read output\"`, k, expectedV) } else if expectedV != actualV { - return fmt.Errorf(`expected data["%s"] = %v, instead got %v"`, k, expectedV, actualV) + return fmt.Errorf(`expected data["%s"] = %v, instead got %v\"`, k, expectedV, actualV) } } return nil } + +// TestConfig_Rotation tests the rotation functionality. +// This is an acceptance test that requires valid credentials. +func TestConfig_Rotation(t *testing.T) { + if !runAcceptanceTests { + t.SkipNow() + } + + tokenType := os.Getenv(envVarTerraformTokenType) + id := os.Getenv(envVarTerraformID) + token := os.Getenv(envVarTerraformToken) + + if tokenType == "" || id == "" || token == "" { + t.Skipf("Skipping rotation test, set %s, %s, and %s to run", envVarTerraformTokenType, envVarTerraformID, envVarTerraformToken) + } + + b, reqStorage := getTestBackend(t) + + t.Run("Test Token Rotation", func(t *testing.T) { + // Create a config with rotation parameters + configData := map[string]interface{}{ + "token": token, + "token_type": tokenType, + "id": id, + } + + err := testConfigCreate(t, b, reqStorage, configData) + require.NoError(t, err) + + // Store original token for comparison + originalToken := token + + // Trigger rotation using the RotateCredential function + err = b.RotateCredential(context.Background(), &logical.Request{ + Storage: reqStorage, + }) + require.NoError(t, err) + + // Read the config again and verify the token has changed + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "config", + Storage: reqStorage, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEqual(t, token, resp.Data["token"]) + }) +} + From b932338bead56077773ff9e112430e08ce504bd3 Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 8 Aug 2025 10:10:40 -0400 Subject: [PATCH 02/13] added new rotate immediately command and old token cleanup working --- .gitignore | 3 + backend.go | 4 +- backend_test.go | 4 +- client.go | 56 +++++++++---------- path_config.go | 131 +++++++++++++++++++++++++++++--------------- path_config_test.go | 6 -- 6 files changed, 121 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index 4a12ab4..a52eae3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ bin tmp .idea .vscode +.DS_Store +config.json +.bin \ No newline at end of file diff --git a/backend.go b/backend.go index 1260190..32e68f3 100644 --- a/backend.go +++ b/backend.go @@ -130,13 +130,13 @@ func (b *tfBackend) rotateRoot(ctx context.Context, req *logical.Request) (*logi return nil, fmt.Errorf("error getting client: %w", err) } - currentToken := config.Token - token, err := client.RotateRootToken(ctx, config.TokenType, config.ID, config.OldToken, currentToken) + token, newID, err := client.RotateRootToken(ctx, config.TokenType, config.ID, config.OldToken) if err != nil { return nil, fmt.Errorf("error rotating root token: %w", err) } config.Token = token + config.ID = newID entry, err := logical.StorageEntryJSON(configStoragePath, config) if err != nil { diff --git a/backend_test.go b/backend_test.go index baccc10..62ef97e 100644 --- a/backend_test.go +++ b/backend_test.go @@ -21,8 +21,8 @@ const ( envVarTerraformUserID = "TF_USER_ID" envVarTerraformAddress = "TF_ADDRESS" // Rotation environment variables - envVarTerraformTokenType = "TF_TOKEN_TYPE" - envVarTerraformID = "TF_ID" + envVarTerraformTokenType = "TF_TOKEN_TYPE" + envVarTerraformID = "TF_ID" ) func getTestBackend(tb testing.TB) (*tfBackend, logical.Storage) { diff --git a/client.go b/client.go index ac7f739..185a237 100644 --- a/client.go +++ b/client.go @@ -46,73 +46,73 @@ func newClient(config *tfConfig) (*client, error) { // RotateRootToken rotates the root token by creating a new token based on the // token type and ID configured in the tfConfig. -func (c *client) RotateRootToken(ctx context.Context, tokenType, id, oldToken, currentToken string) (string, error) { - if tokenType == "" || id == "" { - return "", errors.New("token_type and id must be specified for token rotation") +func (c *client) RotateRootToken(ctx context.Context, tokenType, OldID, oldToken string) (string, string, error) { + if tokenType == "" || OldID == "" { + return "", "", errors.New("token_type and id must be specified for token rotation") } var newToken string + var newID string var err error switch tokenType { case "organization": - newToken, err = c.rotateOrganizationToken(ctx, id) + newToken, newID, err = c.rotateOrganizationToken(ctx, OldID) case "team": - newToken, err = c.rotateTeamToken(ctx, id) + newToken, newID, err = c.rotateTeamToken(ctx, OldID) case "user": - newToken, err = c.rotateUserToken(ctx, id) + newToken, newID, err = c.rotateUserToken(ctx, OldID) default: - return "", fmt.Errorf("unsupported token_type: %s", tokenType) + return "", "", fmt.Errorf("unsupported token_type: %s", tokenType) } if err != nil { - return "", err + return "", "", err } if oldToken == "delete" && (tokenType == "team" || tokenType == "user") { - if err := c.deleteToken(ctx, currentToken, tokenType); err != nil { - return "", fmt.Errorf("failed to delete old token: %w", err) + if err := c.deleteToken(ctx, OldID, tokenType); err != nil { + return "", "", fmt.Errorf("failed to delete old token: %w", err) } } - return newToken, nil + return newToken, newID, nil } -func (c *client) deleteToken(ctx context.Context, token, tokenType string) error { - // Organization tokens are handled differently, so we only need to handle team and user tokens here +func (c *client) deleteToken(ctx context.Context, id, tokenType string) error { if tokenType == "team" { - return c.TeamTokens.Delete(ctx, token) + return c.TeamTokens.DeleteByID(ctx, id) } else if tokenType == "user" { - return c.UserTokens.Delete(ctx, token) + return c.UserTokens.Delete(ctx, id) } return nil } -func (c *client) rotateOrganizationToken(ctx context.Context, orgName string) (string, error) { - // Generate a new organization token +func (c *client) rotateOrganizationToken(ctx context.Context, orgName string) (string, string, error) { newToken, err := c.OrganizationTokens.Create(ctx, orgName) if err != nil { - return "", fmt.Errorf("failed to generate new organization token: %w", err) + return "", "", fmt.Errorf("failed to generate new organization token: %w", err) } - return newToken.Token, nil + return newToken.Token, newToken.ID, nil } -func (c *client) rotateTeamToken(ctx context.Context, teamID string) (string, error) { - // Generate a new team token - newToken, err := c.TeamTokens.Create(ctx, teamID) +func (c *client) rotateTeamToken(ctx context.Context, teamID string) (string, string, error) { + desc := "Rotated by Vault" + newToken, err := c.TeamTokens.CreateWithOptions(ctx, teamID, tfe.TeamTokenCreateOptions{ + Description: &desc, + }) if err != nil { - return "", fmt.Errorf("failed to generate new team token: %w", err) + return "", "", fmt.Errorf("failed to generate new team token: %w", err) } - return newToken.Token, nil + return newToken.Token, newToken.ID, nil } -func (c *client) rotateUserToken(ctx context.Context, userID string) (string, error) { - // Create a new user token +func (c *client) rotateUserToken(ctx context.Context, userID string) (string, string, error) { newToken, err := c.UserTokens.Create(ctx, userID, tfe.UserTokenCreateOptions{ Description: "Rotated by Vault", }) if err != nil { - return "", fmt.Errorf("failed to create new user token: %w", err) + return "", "", fmt.Errorf("failed to create new user token: %w", err) } - return newToken.Token, nil + return newToken.Token, newToken.ID, nil } diff --git a/path_config.go b/path_config.go index 9fd3ff3..6649b54 100644 --- a/path_config.go +++ b/path_config.go @@ -23,6 +23,7 @@ type tfConfig struct { Token string `json:"token"` TokenType string `json:"token_type,omitempty"` + TokenID string `json:"token_id,omitempty"` ID string `json:"id,omitempty"` OldToken string `json:"old_token,omitempty"` Address string `json:"address"` @@ -45,17 +46,22 @@ func pathConfig(b *tfBackend) *framework.Path { Sensitive: true, }, }, + "rotate_token_immediately": { + Type: framework.TypeBool, + Description: "If true and rotation is setup, will immediately rotate the token provided to configuration. Only takes effect when writing the config.", + Default: true, + }, "token_type": { Type: framework.TypeString, Description: "The type of token (organization, team, user). Required for rotation.", }, "id": { Type: framework.TypeString, - Description: "The ID of the organization, team, or user associated with the token. Required for rotation when token_type is specified.", + Description: "The ID of the token. Required for rotation. Token IDs begin with `at-<>`.", }, "old_token": { Type: framework.TypeString, - Description: "The behavior for handling the old token. Can be 'delete' or 'keep'. Defaults to 'delete'.", + Description: "The behavior for handling the old token when rotating. Can be 'delete' or 'keep'. Defaults to 'delete'.", Default: "delete", }, "address": { @@ -132,7 +138,13 @@ func (b *tfBackend) pathConfigRead(ctx context.Context, req *logical.Request, da "base_path": config.BasePath, } - config.PopulateAutomatedRotationData(configData) + if config.ShouldRegisterRotationJob() { + config.PopulateAutomatedRotationData(configData) + configData["token_type"] = config.TokenType + configData["id"] = config.ID + configData["old_token"] = config.OldToken + + } return &logical.Response{ Data: configData, @@ -170,37 +182,6 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d config.Token = data.Get("token").(string) } - if tokenType, ok := data.GetOk("token_type"); ok { - config.TokenType = tokenType.(string) - } else if req.Operation == logical.CreateOperation { - config.TokenType = data.Get("token_type").(string) - } - - if id, ok := data.GetOk("id"); ok { - config.ID = id.(string) - } else if req.Operation == logical.CreateOperation { - config.ID = data.Get("id").(string) - } - - // Validate token_type and id fields for rotation - if config.TokenType != "" { - if config.TokenType != "organization" && config.TokenType != "team" && config.TokenType != "user" { - return logical.ErrorResponse("invalid token_type: must be 'organization', 'team', or 'user'"), nil - } - if config.ID == "" { - return logical.ErrorResponse("id is required when token_type is specified"), nil - } - } - - if oldToken, ok := data.GetOk("old_token"); ok { - config.OldToken = oldToken.(string) - if config.OldToken != "delete" && config.OldToken != "keep" { - return logical.ErrorResponse("invalid old_token: must be 'delete' or 'keep'"), nil - } - } else if req.Operation == logical.CreateOperation { - config.OldToken = data.Get("old_token").(string) - } - // Parse automated rotation fields if err := config.ParseAutomatedRotationFields(data); err != nil { return logical.ErrorResponse(err.Error()), nil @@ -234,11 +215,44 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d if _, err = b.System().RegisterRotationJob(ctx, cfgReq); err != nil { return logical.ErrorResponse("error registering rotation job: %s", err), nil } + + // it should be possible to determine token type from the token itself + // but the go-tfe library does not currently support this: https://github.com/hashicorp/go-tfe/blob/main/user.go#L47 + // so for now we will require the user to specify it + if tokenType, ok := data.GetOk("token_type"); ok { + config.TokenType = tokenType.(string) + } else if req.Operation == logical.CreateOperation { + config.TokenType = data.Get("token_type").(string) + } + + if id, ok := data.GetOk("id"); ok { + config.ID = id.(string) + } else if req.Operation == logical.CreateOperation { + config.ID = data.Get("id").(string) + } + + // Validate token_type and id fields for rotation + if config.TokenType != "" { + if config.TokenType != "organization" && config.TokenType != "team" && config.TokenType != "user" { + return logical.ErrorResponse("invalid token_type: must be 'organization', 'team', or 'user'"), nil + } + if config.ID == "" { + return logical.ErrorResponse("id is required when token_type is specified"), nil + } + } + + if oldToken, ok := data.GetOk("old_token"); ok { + config.OldToken = oldToken.(string) + if config.OldToken != "delete" && config.OldToken != "keep" { + return logical.ErrorResponse("invalid old_token: must be 'delete' or 'keep'"), nil + } + } else if req.Operation == logical.CreateOperation { + config.OldToken = data.Get("old_token").(string) + } } // Save the config - entry, err := logical.StorageEntryJSON(configStoragePath, config) - if err != nil { + if err := putConfigToStorage(ctx, req, config); err != nil { wrappedError := err if performedRotationManagerOperation != "" { b.Logger().Error("write to storage failed but the rotation manager still succeeded.", @@ -249,15 +263,20 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d return nil, wrappedError } - if err := req.Storage.Put(ctx, entry); err != nil { - wrappedError := err - if performedRotationManagerOperation != "" { - b.Logger().Error("write to storage failed but the rotation manager still succeeded.", - "operation", performedRotationManagerOperation, "mount", req.MountPoint, "path", req.Path) - wrappedError = fmt.Errorf("write to storage failed but the rotation manager still succeeded; "+ - "operation=%s, mount=%s, path=%s, storageError=%s", performedRotationManagerOperation, req.MountPoint, req.Path, err) + // If rotation is enabled and the rotate_token_immediately flag is true, + // rotate the token immediately. + if config.ShouldRegisterRotationJob() && data.Get("rotate_token_immediately").(bool) { + newToken, newID, err := rotateOnWrite(ctx, *config) + if err != nil { + b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager stil succeeded", "error", err) + return nil, err + } + config.Token, config.ID = newToken, newID + + if err := putConfigToStorage(ctx, req, config); err != nil { + b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager still succeeded", "error", err) + return nil, fmt.Errorf("error writing updated config after immediate rotation: %w", err) } - return nil, wrappedError } // reset the client so the next invocation will pick up the new configuration @@ -276,6 +295,19 @@ func (b *tfBackend) pathConfigDelete(ctx context.Context, req *logical.Request, return nil, err } +func putConfigToStorage(ctx context.Context, req *logical.Request, config *tfConfig) error { + entry, err := logical.StorageEntryJSON(configStoragePath, config) + if err != nil { + return err + } + + if err := req.Storage.Put(ctx, entry); err != nil { + return err + } + + return nil +} + func getConfig(ctx context.Context, s logical.Storage) (*tfConfig, error) { entry, err := s.Get(ctx, configStoragePath) if err != nil { @@ -295,6 +327,15 @@ func getConfig(ctx context.Context, s logical.Storage) (*tfConfig, error) { return config, nil } +func rotateOnWrite(ctx context.Context, config tfConfig) (string, string, error) { + client, err := newClient(&config) + if err != nil { + return "", "", err + } + + return client.RotateRootToken(ctx, config.TokenType, config.ID, config.OldToken) +} + const pathConfigHelpSynopsis = `Configure the Terraform Cloud / Enterprise backend.` const pathConfigHelpDescription = ` @@ -319,7 +360,7 @@ Example with rotation: vault write terraform/config \ token="your-token" \ token_type="team" \ - id="team-123" \ + id="at-123" \ old_token="delete" \ rotation_period="24h" ` diff --git a/path_config_test.go b/path_config_test.go index 9eefe77..8f0f59e 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "testing" - "time" "github.com/hashicorp/vault/sdk/logical" "github.com/stretchr/testify/require" @@ -168,10 +167,6 @@ func TestConfig_Rotation(t *testing.T) { err := testConfigCreate(t, b, reqStorage, configData) require.NoError(t, err) - // Store original token for comparison - originalToken := token - - // Trigger rotation using the RotateCredential function err = b.RotateCredential(context.Background(), &logical.Request{ Storage: reqStorage, }) @@ -188,4 +183,3 @@ func TestConfig_Rotation(t *testing.T) { require.NotEqual(t, token, resp.Data["token"]) }) } - From aecb6a40b9202905ae9362e81492403cb49ecd38 Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 8 Aug 2025 15:27:30 -0400 Subject: [PATCH 03/13] rotation and revocation working --- backend.go | 46 +----------- client.go | 75 ------------------- path_config.go | 135 +++++++++++++++++++---------------- path_config_rotate.go | 162 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 181 deletions(-) create mode 100644 path_config_rotate.go diff --git a/backend.go b/backend.go index 32e68f3..d7e2517 100644 --- a/backend.go +++ b/backend.go @@ -5,7 +5,6 @@ package tfc import ( "context" - "fmt" "strings" "sync" @@ -52,6 +51,7 @@ func backend() *tfBackend { pathCredentials(&b), }, pathRotateRole(&b), + pathConfigRotate(&b), ), Secrets: []*framework.Secret{ b.terraformToken(), @@ -59,8 +59,7 @@ func backend() *tfBackend { BackendType: logical.TypeLogical, Invalidate: b.invalidate, RotateCredential: func(ctx context.Context, req *logical.Request) error { - _, err := b.rotateRoot(ctx, req) - return err + return b.rotateRootToken(ctx, req) }, } @@ -111,47 +110,6 @@ func (b *tfBackend) getClient(ctx context.Context, s logical.Storage) (*client, return b.client, nil } -func (b *tfBackend) rotateRoot(ctx context.Context, req *logical.Request) (*logical.Response, error) { - config, err := getConfig(ctx, req.Storage) - if err != nil { - return nil, err - } - - if config.Token == "" { - return logical.ErrorResponse("backend is missing token"), nil - } - - if config.TokenType == "" || config.ID == "" { - return logical.ErrorResponse("token_type and id must be configured for token rotation"), nil - } - - client, err := b.getClient(ctx, req.Storage) - if err != nil { - return nil, fmt.Errorf("error getting client: %w", err) - } - - token, newID, err := client.RotateRootToken(ctx, config.TokenType, config.ID, config.OldToken) - if err != nil { - return nil, fmt.Errorf("error rotating root token: %w", err) - } - - config.Token = token - config.ID = newID - - entry, err := logical.StorageEntryJSON(configStoragePath, config) - if err != nil { - return nil, err - } - - if err := req.Storage.Put(ctx, entry); err != nil { - return nil, err - } - - b.reset() - - return nil, nil -} - const backendHelp = ` The Terraform Cloud secrets backend dynamically generates organization and user tokens. diff --git a/client.go b/client.go index 185a237..ee344d2 100644 --- a/client.go +++ b/client.go @@ -4,9 +4,7 @@ package tfc import ( - "context" "errors" - "fmt" "time" "github.com/hashicorp/go-tfe" @@ -43,76 +41,3 @@ func newClient(config *tfConfig) (*client, error) { tfc, }, nil } - -// RotateRootToken rotates the root token by creating a new token based on the -// token type and ID configured in the tfConfig. -func (c *client) RotateRootToken(ctx context.Context, tokenType, OldID, oldToken string) (string, string, error) { - if tokenType == "" || OldID == "" { - return "", "", errors.New("token_type and id must be specified for token rotation") - } - - var newToken string - var newID string - var err error - - switch tokenType { - case "organization": - newToken, newID, err = c.rotateOrganizationToken(ctx, OldID) - case "team": - newToken, newID, err = c.rotateTeamToken(ctx, OldID) - case "user": - newToken, newID, err = c.rotateUserToken(ctx, OldID) - default: - return "", "", fmt.Errorf("unsupported token_type: %s", tokenType) - } - - if err != nil { - return "", "", err - } - - if oldToken == "delete" && (tokenType == "team" || tokenType == "user") { - if err := c.deleteToken(ctx, OldID, tokenType); err != nil { - return "", "", fmt.Errorf("failed to delete old token: %w", err) - } - } - - return newToken, newID, nil -} - -func (c *client) deleteToken(ctx context.Context, id, tokenType string) error { - if tokenType == "team" { - return c.TeamTokens.DeleteByID(ctx, id) - } else if tokenType == "user" { - return c.UserTokens.Delete(ctx, id) - } - return nil -} - -func (c *client) rotateOrganizationToken(ctx context.Context, orgName string) (string, string, error) { - newToken, err := c.OrganizationTokens.Create(ctx, orgName) - if err != nil { - return "", "", fmt.Errorf("failed to generate new organization token: %w", err) - } - return newToken.Token, newToken.ID, nil -} - -func (c *client) rotateTeamToken(ctx context.Context, teamID string) (string, string, error) { - desc := "Rotated by Vault" - newToken, err := c.TeamTokens.CreateWithOptions(ctx, teamID, tfe.TeamTokenCreateOptions{ - Description: &desc, - }) - if err != nil { - return "", "", fmt.Errorf("failed to generate new team token: %w", err) - } - return newToken.Token, newToken.ID, nil -} - -func (c *client) rotateUserToken(ctx context.Context, userID string) (string, string, error) { - newToken, err := c.UserTokens.Create(ctx, userID, tfe.UserTokenCreateOptions{ - Description: "Rotated by Vault", - }) - if err != nil { - return "", "", fmt.Errorf("failed to create new user token: %w", err) - } - return newToken.Token, newToken.ID, nil -} diff --git a/path_config.go b/path_config.go index 6649b54..8d54f11 100644 --- a/path_config.go +++ b/path_config.go @@ -56,6 +56,10 @@ func pathConfig(b *tfBackend) *framework.Path { Description: "The type of token (organization, team, user). Required for rotation.", }, "id": { + Type: framework.TypeString, + Description: "The ID of the entity the token belongs to (organization name, team id, or user id). Required for rotation when old_token=\"delete\".", + }, + "token_id": { Type: framework.TypeString, Description: "The ID of the token. Required for rotation. Token IDs begin with `at-<>`.", }, @@ -138,13 +142,11 @@ func (b *tfBackend) pathConfigRead(ctx context.Context, req *logical.Request, da "base_path": config.BasePath, } - if config.ShouldRegisterRotationJob() { - config.PopulateAutomatedRotationData(configData) - configData["token_type"] = config.TokenType - configData["id"] = config.ID - configData["old_token"] = config.OldToken - - } + config.PopulateAutomatedRotationData(configData) + configData["token_type"] = config.TokenType + configData["token_id"] = config.TokenID + configData["id"] = config.ID + configData["old_token"] = config.OldToken return &logical.Response{ Data: configData, @@ -182,6 +184,46 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d config.Token = data.Get("token").(string) } + // it should be possible to determine token type from the token itself + // but the go-tfe library does not currently support this: https://github.com/hashicorp/go-tfe/blob/main/user.go#L47 + // so for now we will require the user to specify it + if tokenType, ok := data.GetOk("token_type"); ok { + config.TokenType = tokenType.(string) + } else if req.Operation == logical.CreateOperation { + config.TokenType = data.Get("token_type").(string) + } + + if tokenID, ok := data.GetOk("token_id"); ok { + config.TokenID = tokenID.(string) + } else if req.Operation == logical.CreateOperation { + config.TokenID = data.Get("token_id").(string) + } + + if id, ok := data.GetOk("id"); ok { + config.ID = id.(string) + } else if req.Operation == logical.CreateOperation { + config.ID = data.Get("id").(string) + } + + // Validate token_type and id fields for rotation + if config.TokenType != "" { + if config.TokenType != "organization" && config.TokenType != "team" && config.TokenType != "user" { + return logical.ErrorResponse("invalid token_type: must be 'organization', 'team', or 'user'"), nil + } + if config.ID == "" { + return logical.ErrorResponse("id is required when token_type is specified"), nil + } + } + + if oldToken, ok := data.GetOk("old_token"); ok { + config.OldToken = oldToken.(string) + if config.OldToken != "delete" && config.OldToken != "keep" { + return logical.ErrorResponse("invalid old_token: must be 'delete' or 'keep'"), nil + } + } else if req.Operation == logical.CreateOperation { + config.OldToken = data.Get("old_token").(string) + } + // Parse automated rotation fields if err := config.ParseAutomatedRotationFields(data); err != nil { return logical.ErrorResponse(err.Error()), nil @@ -215,40 +257,6 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d if _, err = b.System().RegisterRotationJob(ctx, cfgReq); err != nil { return logical.ErrorResponse("error registering rotation job: %s", err), nil } - - // it should be possible to determine token type from the token itself - // but the go-tfe library does not currently support this: https://github.com/hashicorp/go-tfe/blob/main/user.go#L47 - // so for now we will require the user to specify it - if tokenType, ok := data.GetOk("token_type"); ok { - config.TokenType = tokenType.(string) - } else if req.Operation == logical.CreateOperation { - config.TokenType = data.Get("token_type").(string) - } - - if id, ok := data.GetOk("id"); ok { - config.ID = id.(string) - } else if req.Operation == logical.CreateOperation { - config.ID = data.Get("id").(string) - } - - // Validate token_type and id fields for rotation - if config.TokenType != "" { - if config.TokenType != "organization" && config.TokenType != "team" && config.TokenType != "user" { - return logical.ErrorResponse("invalid token_type: must be 'organization', 'team', or 'user'"), nil - } - if config.ID == "" { - return logical.ErrorResponse("id is required when token_type is specified"), nil - } - } - - if oldToken, ok := data.GetOk("old_token"); ok { - config.OldToken = oldToken.(string) - if config.OldToken != "delete" && config.OldToken != "keep" { - return logical.ErrorResponse("invalid old_token: must be 'delete' or 'keep'"), nil - } - } else if req.Operation == logical.CreateOperation { - config.OldToken = data.Get("old_token").(string) - } } // Save the config @@ -265,19 +273,19 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d // If rotation is enabled and the rotate_token_immediately flag is true, // rotate the token immediately. - if config.ShouldRegisterRotationJob() && data.Get("rotate_token_immediately").(bool) { - newToken, newID, err := rotateOnWrite(ctx, *config) - if err != nil { - b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager stil succeeded", "error", err) - return nil, err - } - config.Token, config.ID = newToken, newID - - if err := putConfigToStorage(ctx, req, config); err != nil { - b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager still succeeded", "error", err) - return nil, fmt.Errorf("error writing updated config after immediate rotation: %w", err) - } - } + // if config.ShouldRegisterRotationJob() && data.Get("rotate_token_immediately").(bool) { + // newToken, newID, err := rotateOnWrite(ctx, *config) + // if err != nil { + // b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager stil succeeded", "error", err) + // return nil, err + // } + // config.Token, config.ID = newToken, newID + + // if err := putConfigToStorage(ctx, req, config); err != nil { + // b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager still succeeded", "error", err) + // return nil, fmt.Errorf("error writing updated config after immediate rotation: %w", err) + // } + // } // reset the client so the next invocation will pick up the new configuration b.reset() @@ -327,14 +335,17 @@ func getConfig(ctx context.Context, s logical.Storage) (*tfConfig, error) { return config, nil } -func rotateOnWrite(ctx context.Context, config tfConfig) (string, string, error) { - client, err := newClient(&config) - if err != nil { - return "", "", err - } - - return client.RotateRootToken(ctx, config.TokenType, config.ID, config.OldToken) -} +// func (b *tfBackend) rotateOnWrite(ctx context.Context, config tfConfig) (string, string, error) { +// b.Logger().Debug("Immediately rotating configuration token on write") +// // client, err := newClient(&config) +// // if err != nil { +// // return "", "", err +// // } +// req := &logical.Request{ +// Storage: b.System().Storage(), +// } +// return b.rotateRootToken(ctx, req) +// } const pathConfigHelpSynopsis = `Configure the Terraform Cloud / Enterprise backend.` diff --git a/path_config_rotate.go b/path_config_rotate.go new file mode 100644 index 0000000..46fef76 --- /dev/null +++ b/path_config_rotate.go @@ -0,0 +1,162 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfc + +import ( + "context" + "errors" + "fmt" + "math/rand" + "time" + + "github.com/hashicorp/go-tfe" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +func pathConfigRotate(b *tfBackend) []*framework.Path { + return []*framework.Path{ + { + Pattern: "config/rotate", + + DisplayAttrs: &framework.DisplayAttributes{ + OperationPrefix: operationPrefixTerraformCloud, + OperationVerb: "rotate", + OperationSuffix: "config", + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: framework.OperationFunc(func(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + return nil, b.rotateRootToken(ctx, req) + }), + ForwardPerformanceStandby: true, + ForwardPerformanceSecondary: true, + }, + }, + + // HelpSynopsis: pathRotateRoleHelpSyn, + // HelpDescription: pathRotateRoleHelpDesc, + }, + } +} + +// RotateRootToken rotates the root token by creating a new token based on the +// token type and ID configured in the tfConfig. +func (b *tfBackend) rotateRootToken(ctx context.Context, req *logical.Request) error { + b.Logger().Info("Rotating configuration token") + config, err := getConfig(ctx, req.Storage) + if err != nil { + return fmt.Errorf("error getting config: %w", err) + } + client, err := b.getClient(ctx, req.Storage) + if err != nil { + return fmt.Errorf("error getting client: %w", err) + + } + + oldTokenID := config.TokenID + if config.TokenType == "" || config.ID == "" || oldTokenID == "" { + return errors.New("token_type, token_id, and id must be specified for token rotation") + } + + var newToken string + var newID string + + switch config.TokenType { + case "organization": + newToken, newID, err = b.rotateOrganizationToken(ctx, client, config.ID) + case "team": + newToken, newID, err = b.rotateTeamToken(ctx, client, config.ID) + case "user": + newToken, newID, err = b.rotateUserToken(ctx, client, config.ID) + default: + return fmt.Errorf("unsupported token_type: %s", config.TokenType) + } + + if err != nil { + return err + } + + config.Token, config.TokenID = newToken, newID + if err := putConfigToStorage(ctx, req, config); err != nil { + b.Logger().Error("error saving new config after rotation: %v", err) + return fmt.Errorf("error saving new config after rotation: %w", err) + } + + if config.OldToken == "delete" && (config.TokenType == "team" || config.TokenType == "user") { + if err := b.deleteToken(ctx, client, oldTokenID, config.TokenType); err != nil { + return fmt.Errorf("failed to delete old token: %w", err) + } + } + + // reset the client so the next invocation will pick up the new configuration + b.reset() + + return nil +} + +func (b *tfBackend) deleteToken(ctx context.Context, c *client, id, tokenType string) error { + if tokenType == "team" { + return c.TeamTokens.DeleteByID(ctx, id) + } else if tokenType == "user" { + return c.UserTokens.Delete(ctx, id) + } + return nil +} + +func (b *tfBackend) rotateOrganizationToken(ctx context.Context, c *client, orgName string) (string, string, error) { + b.Logger().Debug("Creating new organization token for ", orgName) + newToken, err := c.OrganizationTokens.Create(ctx, orgName) + if err != nil { + return "", "", fmt.Errorf("failed to generate new organization token: %w", err) + } + return newToken.Token, newToken.ID, nil +} + +func (b *tfBackend) rotateTeamToken(ctx context.Context, c *client, teamID string) (string, string, error) { + desc := generateRandomDescriptionString("Rotated by Vault") + b.Logger().Debug("Creating new team token with description:", teamID, desc) + newToken, err := c.TeamTokens.CreateWithOptions(ctx, teamID, tfe.TeamTokenCreateOptions{ + Description: &desc, + }) + if err != nil { + return "", "", fmt.Errorf("failed to generate new team token: %w", err) + } + return newToken.Token, newToken.ID, nil +} + +func (b *tfBackend) rotateUserToken(ctx context.Context, c *client, userID string) (string, string, error) { + desc := generateRandomDescriptionString("Rotated by Vault") + b.Logger().Debug("Creating new user token with description:", userID, desc) + + newToken, err := c.UserTokens.Create(ctx, userID, tfe.UserTokenCreateOptions{ + Description: desc, + }) + if err != nil { + return "", "", fmt.Errorf("failed to create new user token: %w", err) + } + return newToken.Token, newToken.ID, nil +} + +func generateRandomDescriptionString(description string) string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + result := make([]byte, 5) + for i := range result { + result[i] = chars[r.Intn(len(chars))] + } + + return fmt.Sprintf("%s (%s)", description, string(result)) +} + +// const pathRotateRoleHelpSyn = ` +// Request to rotate the credentials for a team or organization. +// ` + +// const pathRotateRoleHelpDesc = ` +// This path attempts to rotate the credentials for the given team or organization role. +// This endpoint returns an error if attempting to rotate a user role. +// ` From cae1191f060bd1c693f514916c62fc649e414e8d Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Mon, 22 Sep 2025 15:53:47 -0400 Subject: [PATCH 04/13] rotation tests working --- backend_test.go | 1 + path_config_test.go | 78 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/backend_test.go b/backend_test.go index 62ef97e..ce848e8 100644 --- a/backend_test.go +++ b/backend_test.go @@ -22,6 +22,7 @@ const ( envVarTerraformAddress = "TF_ADDRESS" // Rotation environment variables envVarTerraformTokenType = "TF_TOKEN_TYPE" + envVarTerraformTokenID = "TF_TOKEN_ID" envVarTerraformID = "TF_ID" ) diff --git a/path_config_test.go b/path_config_test.go index 8f0f59e..50a09f7 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -9,7 +9,12 @@ import ( "os" "testing" + "github.com/hashicorp/vault/sdk/helper/automatedrotationutil" + "github.com/hashicorp/vault/sdk/helper/pluginidentityutil" + "github.com/hashicorp/vault/sdk/helper/pluginutil" "github.com/hashicorp/vault/sdk/logical" + "github.com/hashicorp/vault/sdk/rotation" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -149,12 +154,21 @@ func TestConfig_Rotation(t *testing.T) { tokenType := os.Getenv(envVarTerraformTokenType) id := os.Getenv(envVarTerraformID) token := os.Getenv(envVarTerraformToken) + tokenID := os.Getenv(envVarTerraformTokenID) - if tokenType == "" || id == "" || token == "" { - t.Skipf("Skipping rotation test, set %s, %s, and %s to run", envVarTerraformTokenType, envVarTerraformID, envVarTerraformToken) + if tokenType == "" || id == "" || token == "" || tokenID == "" { + t.Skipf("Skipping rotation test, set %s, %s, %s, and %s to run", envVarTerraformTokenType, envVarTerraformID, envVarTerraformToken, envVarTerraformTokenID) } - b, reqStorage := getTestBackend(t) + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + config.System = &testSystemView{} + ctx := context.Background() + + b := backend() + if err := b.Setup(ctx, config); err != nil { + t.Fatal(err) + } t.Run("Test Token Rotation", func(t *testing.T) { // Create a config with rotation parameters @@ -162,13 +176,14 @@ func TestConfig_Rotation(t *testing.T) { "token": token, "token_type": tokenType, "id": id, + "token_id": tokenID, } - err := testConfigCreate(t, b, reqStorage, configData) + err := testConfigCreate(t, b, config.StorageView, configData) require.NoError(t, err) err = b.RotateCredential(context.Background(), &logical.Request{ - Storage: reqStorage, + Storage: config.StorageView, }) require.NoError(t, err) @@ -176,10 +191,61 @@ func TestConfig_Rotation(t *testing.T) { resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "config", - Storage: reqStorage, + Storage: config.StorageView, }) require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, token, resp.Data["token"]) }) } + +// TestBackend_PathConfig_RegisterRotation tests that configuration +// and registering a root credential returns an immediate error. +func TestBackend_PathConfig_RegisterRotation(t *testing.T) { + config := logical.TestBackendConfig() + config.StorageView = &logical.InmemStorage{} + config.System = &testSystemView{} + ctx := context.Background() + + b := backend() + if err := b.Setup(ctx, config); err != nil { + t.Fatal(err) + } + + configData := map[string]interface{}{ + "token": "token-value", + "token_type": "user", + "id": "user-id", + "token_id": "token-id-value", + "rotation_schedule": "*/1 * * * *", + "rotation_window": 120, + } + + configReq := &logical.Request{ + Operation: logical.CreateOperation, + Storage: config.StorageView, + Path: "config", + Data: configData, + } + + resp, err := b.HandleRequest(context.Background(), configReq) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.ErrorContains(t, resp.Error(), automatedrotationutil.ErrRotationManagerUnsupported.Error()) +} + +type testSystemView struct { + logical.StaticSystemView +} + +func (d testSystemView) GenerateIdentityToken(_ context.Context, _ *pluginutil.IdentityTokenRequest) (*pluginutil.IdentityTokenResponse, error) { + return nil, pluginidentityutil.ErrPluginWorkloadIdentityUnsupported +} + +func (d testSystemView) RegisterRotationJob(_ context.Context, _ *rotation.RotationJobConfigureRequest) (string, error) { + return "", automatedrotationutil.ErrRotationManagerUnsupported +} + +func (d testSystemView) DeregisterRotationJob(_ context.Context, _ *rotation.RotationJobDeregisterRequest) error { + return nil +} From 7f96edc1461c25e901c8c0d37b052cd656e991e3 Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Mon, 22 Sep 2025 16:37:28 -0400 Subject: [PATCH 05/13] implement immediate rotation --- path_config.go | 37 +++++++++++-------------------------- path_config_test.go | 4 ++-- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/path_config.go b/path_config.go index 8d54f11..23be11d 100644 --- a/path_config.go +++ b/path_config.go @@ -273,19 +273,15 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d // If rotation is enabled and the rotate_token_immediately flag is true, // rotate the token immediately. - // if config.ShouldRegisterRotationJob() && data.Get("rotate_token_immediately").(bool) { - // newToken, newID, err := rotateOnWrite(ctx, *config) - // if err != nil { - // b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager stil succeeded", "error", err) - // return nil, err - // } - // config.Token, config.ID = newToken, newID - - // if err := putConfigToStorage(ctx, req, config); err != nil { - // b.Logger().Error("error immediately rotating token when writing backend configuration. rotation manager still succeeded", "error", err) - // return nil, fmt.Errorf("error writing updated config after immediate rotation: %w", err) - // } - // } + if config.ShouldRegisterRotationJob() && data.Get("rotate_token_immediately").(bool) { + b.Logger().Debug("Immediately rotating configuration token on write") + err = b.rotateRootToken(ctx, req) + + if err != nil { + b.Logger().Error("error immediately rotating token when writing backend configuration. initial rotation manager setup succeeded", "error", err) + return nil, err + } + } // reset the client so the next invocation will pick up the new configuration b.reset() @@ -335,18 +331,6 @@ func getConfig(ctx context.Context, s logical.Storage) (*tfConfig, error) { return config, nil } -// func (b *tfBackend) rotateOnWrite(ctx context.Context, config tfConfig) (string, string, error) { -// b.Logger().Debug("Immediately rotating configuration token on write") -// // client, err := newClient(&config) -// // if err != nil { -// // return "", "", err -// // } -// req := &logical.Request{ -// Storage: b.System().Storage(), -// } -// return b.rotateRootToken(ctx, req) -// } - const pathConfigHelpSynopsis = `Configure the Terraform Cloud / Enterprise backend.` const pathConfigHelpDescription = ` @@ -371,7 +355,8 @@ Example with rotation: vault write terraform/config \ token="your-token" \ token_type="team" \ - id="at-123" \ + token_id="at-123" \ + id="team-123" \ old_token="delete" \ rotation_period="24h" ` diff --git a/path_config_test.go b/path_config_test.go index 50a09f7..218c2f0 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -170,7 +170,7 @@ func TestConfig_Rotation(t *testing.T) { t.Fatal(err) } - t.Run("Test Token Rotation", func(t *testing.T) { + t.Run("Test Root Token Rotation", func(t *testing.T) { // Create a config with rotation parameters configData := map[string]interface{}{ "token": token, @@ -182,7 +182,7 @@ func TestConfig_Rotation(t *testing.T) { err := testConfigCreate(t, b, config.StorageView, configData) require.NoError(t, err) - err = b.RotateCredential(context.Background(), &logical.Request{ + err = b.rotateRootToken(context.Background(), &logical.Request{ Storage: config.StorageView, }) require.NoError(t, err) From 469dbb35db610d76ec2990aaec151ffa95c18aca Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Tue, 23 Sep 2025 08:56:49 -0400 Subject: [PATCH 06/13] help docs --- path_config.go | 18 +++++++++++------- path_config_rotate.go | 29 +++++++++++++++++++---------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/path_config.go b/path_config.go index 23be11d..4fd922c 100644 --- a/path_config.go +++ b/path_config.go @@ -331,22 +331,26 @@ func getConfig(ctx context.Context, s logical.Storage) (*tfConfig, error) { return config, nil } -const pathConfigHelpSynopsis = `Configure the Terraform Cloud / Enterprise backend.` +const pathConfigHelpSynopsis = `Configure the root credentials that are used to +generate HCP Terraform / Terraform Enterprise tokens.` const pathConfigHelpDescription = ` -The Terraform Cloud / Enterprise secret backend requires credentials for managing -organization and team tokens for Terraform Cloud or Enterprise. This endpoint -is used to configure those credentials and the default values for the backend in general. - -You must specify a Terraform Cloud or Enterprise token with organization access -to allow Vault to create tokens. +The HCP Terraform / Terraform Enterprise secret backend requires credentials for +generating dynamic user, team, or organization tokens. This endpoint is used to +configure those credentials and the default values for the backend in general. +The credential must have access to create tokens for the organization or teams +you wish Vault to manage. If you are running Terraform Enterprise, you can specify the address and base path for your instance and API endpoint. +Root credentials can be rotated if the token_type, id, and token_id fields are +set in the configuration. + Automatic token rotation (requires Vault Enterprise): For automatic token rotation, specify: - token_type: The type of token (organization, team, user) +- token_id: The ID of the token to rotate - id: The ID of the organization, team, or user associated with the token - old_token: How to handle the old token ("delete" or "keep", defaults to "delete") - rotation_period or rotation_schedule: When to rotate the token diff --git a/path_config_rotate.go b/path_config_rotate.go index 46fef76..cb8ba2b 100644 --- a/path_config_rotate.go +++ b/path_config_rotate.go @@ -36,8 +36,8 @@ func pathConfigRotate(b *tfBackend) []*framework.Path { }, }, - // HelpSynopsis: pathRotateRoleHelpSyn, - // HelpDescription: pathRotateRoleHelpDesc, + HelpSynopsis: pathRotateConfigHelpSyn, + HelpDescription: pathRotateConfigHelpDesc, }, } } @@ -152,11 +152,20 @@ func generateRandomDescriptionString(description string) string { return fmt.Sprintf("%s (%s)", description, string(result)) } -// const pathRotateRoleHelpSyn = ` -// Request to rotate the credentials for a team or organization. -// ` - -// const pathRotateRoleHelpDesc = ` -// This path attempts to rotate the credentials for the given team or organization role. -// This endpoint returns an error if attempting to rotate a user role. -// ` +const pathRotateConfigHelpSyn = ` +Request to rotate the root token for a user, team, or organization. +` + +const pathRotateConfigHelpDesc = ` +This path attempts to rotate the root token of the secret engine configuration. +Rotation requires that the token_type, id, and token_id fields are set in the +configuration. If the old_token field is set to "delete" and the token_type is +set to "team" or "user", the old token will be deleted after a successful rotation. + +Automatic rotation can be configured by setting the rotation_period field in the +configuration. If rotation is configured, the token will be rotated automatically +after the specified period has elapsed. The rotate_token_immediately field can be +set to true to rotate the token immediately after writing the configuration, +preventing the human provided token from living a full rotation period. Automatic +rotation is only supported for Vault. +` From 0b53edce2376fbb8935a533a4081158930bb5b63 Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Tue, 23 Sep 2025 09:12:00 -0400 Subject: [PATCH 07/13] rm immediate rotation param --- path_config.go | 17 ----------------- path_config_rotate.go | 6 ++---- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/path_config.go b/path_config.go index 4fd922c..0c4f5e3 100644 --- a/path_config.go +++ b/path_config.go @@ -46,11 +46,6 @@ func pathConfig(b *tfBackend) *framework.Path { Sensitive: true, }, }, - "rotate_token_immediately": { - Type: framework.TypeBool, - Description: "If true and rotation is setup, will immediately rotate the token provided to configuration. Only takes effect when writing the config.", - Default: true, - }, "token_type": { Type: framework.TypeString, Description: "The type of token (organization, team, user). Required for rotation.", @@ -271,18 +266,6 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d return nil, wrappedError } - // If rotation is enabled and the rotate_token_immediately flag is true, - // rotate the token immediately. - if config.ShouldRegisterRotationJob() && data.Get("rotate_token_immediately").(bool) { - b.Logger().Debug("Immediately rotating configuration token on write") - err = b.rotateRootToken(ctx, req) - - if err != nil { - b.Logger().Error("error immediately rotating token when writing backend configuration. initial rotation manager setup succeeded", "error", err) - return nil, err - } - } - // reset the client so the next invocation will pick up the new configuration b.reset() diff --git a/path_config_rotate.go b/path_config_rotate.go index cb8ba2b..4555563 100644 --- a/path_config_rotate.go +++ b/path_config_rotate.go @@ -164,8 +164,6 @@ set to "team" or "user", the old token will be deleted after a successful rotati Automatic rotation can be configured by setting the rotation_period field in the configuration. If rotation is configured, the token will be rotated automatically -after the specified period has elapsed. The rotate_token_immediately field can be -set to true to rotate the token immediately after writing the configuration, -preventing the human provided token from living a full rotation period. Automatic -rotation is only supported for Vault. +after the specified period has elapsed. Automatic rotation is only supported for +Vault Enterprise. ` From 9fa5d7ca736196b51863e7a171ebbac46a85827e Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 10 Oct 2025 09:12:03 -0400 Subject: [PATCH 08/13] rename to rotate-root for convention --- path_config_rotate.go => path_config_rotate_root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename path_config_rotate.go => path_config_rotate_root.go (99%) diff --git a/path_config_rotate.go b/path_config_rotate_root.go similarity index 99% rename from path_config_rotate.go rename to path_config_rotate_root.go index 4555563..8730d66 100644 --- a/path_config_rotate.go +++ b/path_config_rotate_root.go @@ -18,7 +18,7 @@ import ( func pathConfigRotate(b *tfBackend) []*framework.Path { return []*framework.Path{ { - Pattern: "config/rotate", + Pattern: "config/rotate-root", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: operationPrefixTerraformCloud, From 4e0064ee251342aa19ce162e3e5a87169eb5c6eb Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 6 Mar 2026 10:45:47 -0500 Subject: [PATCH 09/13] use account details to fetch token type and entity id --- account_details.go | 95 ++++++++++++++++++++++++++++++++++++++ backend.go | 9 ++-- backend_test.go | 22 ++++----- path_config.go | 55 +++++++--------------- path_config_rotate_root.go | 21 +++++++-- path_config_test.go | 23 ++++----- 6 files changed, 151 insertions(+), 74 deletions(-) create mode 100644 account_details.go diff --git a/account_details.go b/account_details.go new file mode 100644 index 0000000..b5f8b42 --- /dev/null +++ b/account_details.go @@ -0,0 +1,95 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// accountDetailsResponse represents the JSON response from the TFC/TFE +// account/details API endpoint. +type accountDetailsResponse struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + Username string `json:"username"` + } `json:"attributes"` + Relationships struct { + AuthenticatedResource struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"data"` + } `json:"authenticated-resource"` + } `json:"relationships"` + } `json:"data"` +} + +// resolveTokenIdentity calls the TFC/TFE account/details API to determine +// the token type (organization, team, or user) and the associated entity ID. +// +// Organization tokens have usernames starting with "api-org-". The org name +// is extracted by splitting on "-" and dropping the first two and last parts. +// +// Team tokens have usernames starting with "api-team-". The team ID is +// taken from the authenticated-resource relationship. +// +// All other tokens are treated as user tokens, using data.id directly. +func resolveTokenIdentity(ctx context.Context, address, basePath, token string) (tokenType string, id string, err error) { + url := strings.TrimRight(address, "/") + "/" + strings.Trim(basePath, "/") + "/account/details" + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", "", fmt.Errorf("error creating account details request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/vnd.api+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("error calling account/details: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("account/details returned status %d", resp.StatusCode) + } + + var details accountDetailsResponse + if err := json.NewDecoder(resp.Body).Decode(&details); err != nil { + return "", "", fmt.Errorf("error decoding account/details response: %w", err) + } + + username := details.Data.Attributes.Username + + if strings.HasPrefix(username, "api-org-") { + // Organization token: extract org name from username. + // Username format: "api-org--" + // Organization names can contain "-", so we split on "-" and drop + // the first two parts ("api", "org") and the last part (random suffix). + parts := strings.Split(username, "-") + if len(parts) < 4 { + return "", "", fmt.Errorf("unexpected organization token username format: %s", username) + } + orgName := strings.Join(parts[2:len(parts)-1], "-") + return "organization", orgName, nil + } + + if strings.HasPrefix(username, "api-team-") { + // Team token: get team ID from the authenticated-resource relationship. + teamID := details.Data.Relationships.AuthenticatedResource.Data.ID + if teamID == "" { + return "", "", fmt.Errorf("team token detected but authenticated-resource ID is missing") + } + return "team", teamID, nil + } + + // User token: use the user ID directly. + return "user", details.Data.ID, nil +} diff --git a/backend.go b/backend.go index d7e2517..d309a04 100644 --- a/backend.go +++ b/backend.go @@ -26,12 +26,15 @@ func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, type tfBackend struct { *framework.Backend - lock sync.RWMutex - client *client + lock sync.RWMutex + client *client + resolveTokenIdentityFunc func(ctx context.Context, address, basePath, token string) (string, string, error) } func backend() *tfBackend { - b := tfBackend{} + b := tfBackend{ + resolveTokenIdentityFunc: resolveTokenIdentity, + } b.Backend = &framework.Backend{ Help: strings.TrimSpace(backendHelp), diff --git a/backend_test.go b/backend_test.go index ce848e8..d9dbbd2 100644 --- a/backend_test.go +++ b/backend_test.go @@ -21,9 +21,7 @@ const ( envVarTerraformUserID = "TF_USER_ID" envVarTerraformAddress = "TF_ADDRESS" // Rotation environment variables - envVarTerraformTokenType = "TF_TOKEN_TYPE" - envVarTerraformTokenID = "TF_TOKEN_ID" - envVarTerraformID = "TF_ID" + envVarTerraformTokenID = "TF_TOKEN_ID" ) func getTestBackend(tb testing.TB) (*tfBackend, logical.Storage) { @@ -32,14 +30,20 @@ func getTestBackend(tb testing.TB) (*tfBackend, logical.Storage) { config := logical.TestBackendConfig() config.StorageView = new(logical.InmemStorage) config.Logger = hclog.NewNullLogger() - config.System = logical.TestSystemView() + config.System = &testSystemView{} b, err := Factory(context.Background(), config) if err != nil { tb.Fatal(err) } - return b.(*tfBackend), config.StorageView + tfb := b.(*tfBackend) + // Use a no-op resolver for unit tests (no real TFC API available) + tfb.resolveTokenIdentityFunc = func(ctx context.Context, address, basePath, token string) (string, string, error) { + return "", "", nil + } + + return tfb, config.StorageView } var runAcceptanceTests = os.Getenv(envVarRunAccTests) == "1" @@ -67,14 +71,6 @@ func (e *testEnv) AddConfig(t *testing.T) { "token": e.Token, } - // Add rotation parameters if environment variables are set - if tokenType := os.Getenv(envVarTerraformTokenType); tokenType != "" { - data["token_type"] = tokenType - } - if id := os.Getenv(envVarTerraformID); id != "" { - data["id"] = id - } - req := &logical.Request{ Operation: logical.CreateOperation, Path: "config", diff --git a/path_config.go b/path_config.go index 0c4f5e3..8367410 100644 --- a/path_config.go +++ b/path_config.go @@ -46,14 +46,6 @@ func pathConfig(b *tfBackend) *framework.Path { Sensitive: true, }, }, - "token_type": { - Type: framework.TypeString, - Description: "The type of token (organization, team, user). Required for rotation.", - }, - "id": { - Type: framework.TypeString, - Description: "The ID of the entity the token belongs to (organization name, team id, or user id). Required for rotation when old_token=\"delete\".", - }, "token_id": { Type: framework.TypeString, Description: "The ID of the token. Required for rotation. Token IDs begin with `at-<>`.", @@ -173,19 +165,13 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d config.BasePath = data.Get("base_path").(string) } + tokenChanged := false if token, ok := data.GetOk("token"); ok { config.Token = token.(string) + tokenChanged = true } else if req.Operation == logical.CreateOperation { config.Token = data.Get("token").(string) - } - - // it should be possible to determine token type from the token itself - // but the go-tfe library does not currently support this: https://github.com/hashicorp/go-tfe/blob/main/user.go#L47 - // so for now we will require the user to specify it - if tokenType, ok := data.GetOk("token_type"); ok { - config.TokenType = tokenType.(string) - } else if req.Operation == logical.CreateOperation { - config.TokenType = data.Get("token_type").(string) + tokenChanged = true } if tokenID, ok := data.GetOk("token_id"); ok { @@ -194,22 +180,6 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d config.TokenID = data.Get("token_id").(string) } - if id, ok := data.GetOk("id"); ok { - config.ID = id.(string) - } else if req.Operation == logical.CreateOperation { - config.ID = data.Get("id").(string) - } - - // Validate token_type and id fields for rotation - if config.TokenType != "" { - if config.TokenType != "organization" && config.TokenType != "team" && config.TokenType != "user" { - return logical.ErrorResponse("invalid token_type: must be 'organization', 'team', or 'user'"), nil - } - if config.ID == "" { - return logical.ErrorResponse("id is required when token_type is specified"), nil - } - } - if oldToken, ok := data.GetOk("old_token"); ok { config.OldToken = oldToken.(string) if config.OldToken != "delete" && config.OldToken != "keep" { @@ -219,6 +189,16 @@ func (b *tfBackend) pathConfigWrite(ctx context.Context, req *logical.Request, d config.OldToken = data.Get("old_token").(string) } + // Auto-detect token type and ID from the TFC/TFE account/details API + if tokenChanged && config.Token != "" { + tokenType, id, err := b.resolveTokenIdentityFunc(ctx, config.Address, config.BasePath, config.Token) + if err != nil { + return logical.ErrorResponse("failed to auto-detect token identity: %s", err), nil + } + config.TokenType = tokenType + config.ID = id + } + // Parse automated rotation fields if err := config.ParseAutomatedRotationFields(data); err != nil { return logical.ErrorResponse(err.Error()), nil @@ -327,23 +307,20 @@ you wish Vault to manage. If you are running Terraform Enterprise, you can specify the address and base path for your instance and API endpoint. -Root credentials can be rotated if the token_type, id, and token_id fields are -set in the configuration. +Root credentials can be rotated if the token_id field is set in the +configuration. The token type and entity ID are automatically detected +from the token via the account/details API. Automatic token rotation (requires Vault Enterprise): For automatic token rotation, specify: -- token_type: The type of token (organization, team, user) - token_id: The ID of the token to rotate -- id: The ID of the organization, team, or user associated with the token - old_token: How to handle the old token ("delete" or "keep", defaults to "delete") - rotation_period or rotation_schedule: When to rotate the token Example with rotation: vault write terraform/config \ token="your-token" \ - token_type="team" \ token_id="at-123" \ - id="team-123" \ old_token="delete" \ rotation_period="24h" ` diff --git a/path_config_rotate_root.go b/path_config_rotate_root.go index 8730d66..1889dda 100644 --- a/path_config_rotate_root.go +++ b/path_config_rotate_root.go @@ -56,9 +56,19 @@ func (b *tfBackend) rotateRootToken(ctx context.Context, req *logical.Request) e } + // Resolve token type and ID if not already set (e.g., legacy config) + if config.TokenType == "" || config.ID == "" { + tokenType, id, err := b.resolveTokenIdentityFunc(ctx, config.Address, config.BasePath, config.Token) + if err != nil { + return fmt.Errorf("failed to auto-detect token identity: %w", err) + } + config.TokenType = tokenType + config.ID = id + } + oldTokenID := config.TokenID - if config.TokenType == "" || config.ID == "" || oldTokenID == "" { - return errors.New("token_type, token_id, and id must be specified for token rotation") + if oldTokenID == "" { + return errors.New("token_id must be specified for token rotation") } var newToken string @@ -158,9 +168,10 @@ Request to rotate the root token for a user, team, or organization. const pathRotateConfigHelpDesc = ` This path attempts to rotate the root token of the secret engine configuration. -Rotation requires that the token_type, id, and token_id fields are set in the -configuration. If the old_token field is set to "delete" and the token_type is -set to "team" or "user", the old token will be deleted after a successful rotation. +The token type and entity ID are automatically detected from the token via the +account/details API. Rotation requires that the token_id field is set in the +configuration. If the old_token field is set to "delete" and the token type is +"team" or "user", the old token will be deleted after a successful rotation. Automatic rotation can be configured by setting the rotation_period field in the configuration. If rotation is configured, the token will be rotated automatically diff --git a/path_config_test.go b/path_config_test.go index 218c2f0..d337b83 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -127,10 +127,6 @@ func testConfigRead(t *testing.T, b logical.Backend, s logical.Storage, expected return resp.Error() } - if len(expected) != len(resp.Data) { - return fmt.Errorf("read data mismatch (expected %d values, got %d)", len(expected), len(resp.Data)) - } - for k, expectedV := range expected { actualV, ok := resp.Data[k] @@ -151,13 +147,11 @@ func TestConfig_Rotation(t *testing.T) { t.SkipNow() } - tokenType := os.Getenv(envVarTerraformTokenType) - id := os.Getenv(envVarTerraformID) token := os.Getenv(envVarTerraformToken) tokenID := os.Getenv(envVarTerraformTokenID) - if tokenType == "" || id == "" || token == "" || tokenID == "" { - t.Skipf("Skipping rotation test, set %s, %s, %s, and %s to run", envVarTerraformTokenType, envVarTerraformID, envVarTerraformToken, envVarTerraformTokenID) + if token == "" || tokenID == "" { + t.Skipf("Skipping rotation test, set %s and %s to run", envVarTerraformToken, envVarTerraformTokenID) } config := logical.TestBackendConfig() @@ -172,11 +166,10 @@ func TestConfig_Rotation(t *testing.T) { t.Run("Test Root Token Rotation", func(t *testing.T) { // Create a config with rotation parameters + // token_type and id are auto-detected from the token configData := map[string]interface{}{ - "token": token, - "token_type": tokenType, - "id": id, - "token_id": tokenID, + "token": token, + "token_id": tokenID, } err := testConfigCreate(t, b, config.StorageView, configData) @@ -211,11 +204,13 @@ func TestBackend_PathConfig_RegisterRotation(t *testing.T) { if err := b.Setup(ctx, config); err != nil { t.Fatal(err) } + // Mock the resolver for unit tests + b.resolveTokenIdentityFunc = func(ctx context.Context, address, basePath, token string) (string, string, error) { + return "user", "user-123", nil + } configData := map[string]interface{}{ "token": "token-value", - "token_type": "user", - "id": "user-id", "token_id": "token-id-value", "rotation_schedule": "*/1 * * * *", "rotation_window": 120, From f4693fe30e1311562c53c91175ba61a1c66874cd Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 6 Mar 2026 11:00:34 -0500 Subject: [PATCH 10/13] agent suggested nits --- .gitignore | 2 +- path_config.go | 24 ++++++++++++++++++++---- path_config_test.go | 10 +++++++--- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index a52eae3..b3b6d95 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,4 @@ tmp .vscode .DS_Store config.json -.bin \ No newline at end of file +.bin diff --git a/path_config.go b/path_config.go index 8367410..6687a12 100644 --- a/path_config.go +++ b/path_config.go @@ -46,6 +46,14 @@ func pathConfig(b *tfBackend) *framework.Path { Sensitive: true, }, }, + "token_type": { + Type: framework.TypeString, + Description: "The type of token (organization, team, user). Auto-detected from the token via the account/details API. Read-only.", + }, + "id": { + Type: framework.TypeString, + Description: "The ID of the entity the token belongs to (organization name, team id, or user id). Auto-detected from the token. Read-only.", + }, "token_id": { Type: framework.TypeString, Description: "The ID of the token. Required for rotation. Token IDs begin with `at-<>`.", @@ -130,10 +138,18 @@ func (b *tfBackend) pathConfigRead(ctx context.Context, req *logical.Request, da } config.PopulateAutomatedRotationData(configData) - configData["token_type"] = config.TokenType - configData["token_id"] = config.TokenID - configData["id"] = config.ID - configData["old_token"] = config.OldToken + if config.TokenType != "" { + configData["token_type"] = config.TokenType + } + if config.TokenID != "" { + configData["token_id"] = config.TokenID + } + if config.ID != "" { + configData["id"] = config.ID + } + if config.OldToken != "" { + configData["old_token"] = config.OldToken + } return &logical.Response{ Data: configData, diff --git a/path_config_test.go b/path_config_test.go index d337b83..4b9aeee 100644 --- a/path_config_test.go +++ b/path_config_test.go @@ -131,9 +131,9 @@ func testConfigRead(t *testing.T, b logical.Backend, s logical.Storage, expected actualV, ok := resp.Data[k] if !ok { - return fmt.Errorf(`expected data["%s"] = %v but was not included in read output\"`, k, expectedV) + return fmt.Errorf(`expected data["%s"] = %v but was not included in read output`, k, expectedV) } else if expectedV != actualV { - return fmt.Errorf(`expected data["%s"] = %v, instead got %v\"`, k, expectedV, actualV) + return fmt.Errorf(`expected data["%s"] = %v, instead got %v`, k, expectedV, actualV) } } @@ -180,7 +180,7 @@ func TestConfig_Rotation(t *testing.T) { }) require.NoError(t, err) - // Read the config again and verify the token has changed + // Read the config again and verify the token and token_id have changed resp, err := b.HandleRequest(context.Background(), &logical.Request{ Operation: logical.ReadOperation, Path: "config", @@ -189,6 +189,10 @@ func TestConfig_Rotation(t *testing.T) { require.NoError(t, err) require.NotNil(t, resp) require.NotEqual(t, token, resp.Data["token"]) + + newTokenID, ok := resp.Data["token_id"] + require.True(t, ok, "token_id should be present in config after rotation") + require.NotEqual(t, tokenID, newTokenID, "token_id should change after rotation") }) } From a7153cd70dfcbe31a0ea838532d05a0350a273aa Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 6 Mar 2026 11:06:55 -0500 Subject: [PATCH 11/13] add accoutn details tests --- account_details_test.go | 140 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 account_details_test.go diff --git a/account_details_test.go b/account_details_test.go new file mode 100644 index 0000000..2baa106 --- /dev/null +++ b/account_details_test.go @@ -0,0 +1,140 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfc + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveTokenIdentity(t *testing.T) { + tests := []struct { + name string + responseCode int + responseBody string + wantTokenType string + wantID string + wantErrContain string + }{ + { + name: "organization token - simple name", + responseCode: http.StatusOK, + responseBody: `{ + "data": { + "id": "user-abc123", + "type": "users", + "attributes": { "username": "api-org-mullen-14JAXvyITM" }, + "relationships": {} + } + }`, + wantTokenType: "organization", + wantID: "mullen", + }, + { + name: "organization token - hyphenated name", + responseCode: http.StatusOK, + responseBody: `{ + "data": { + "id": "user-abc123", + "type": "users", + "attributes": { "username": "api-org-my-cool-org-14JAXvyITM" }, + "relationships": {} + } + }`, + wantTokenType: "organization", + wantID: "my-cool-org", + }, + { + name: "team token", + responseCode: http.StatusOK, + responseBody: `{ + "data": { + "id": "user-xyz", + "type": "users", + "attributes": { "username": "api-team-myteam-abc123" }, + "relationships": { + "authenticated-resource": { + "data": { "id": "team-RGhi7xU4NWWmp1MQ", "type": "teams" } + } + } + } + }`, + wantTokenType: "team", + wantID: "team-RGhi7xU4NWWmp1MQ", + }, + { + name: "user token", + responseCode: http.StatusOK, + responseBody: `{ + "data": { + "id": "user-V3R563qtqNzY6fA1", + "type": "users", + "attributes": { "username": "drew-mullen" }, + "relationships": {} + } + }`, + wantTokenType: "user", + wantID: "user-V3R563qtqNzY6fA1", + }, + { + name: "team token - missing relationship", + responseCode: http.StatusOK, + responseBody: `{"data":{"id":"user-x","type":"users","attributes":{"username":"api-team-foo-bar"},"relationships":{}}}`, + wantErrContain: "authenticated-resource ID is missing", + }, + { + name: "org token - username too short", + responseCode: http.StatusOK, + responseBody: `{"data":{"id":"user-x","type":"users","attributes":{"username":"api-org-x"},"relationships":{}}}`, + wantErrContain: "unexpected organization token username format", + }, + { + name: "unauthorized", + responseCode: http.StatusUnauthorized, + responseBody: `{"errors":["unauthorized"]}`, + wantErrContain: "status 401", + }, + { + name: "bad json", + responseCode: http.StatusOK, + responseBody: `not json`, + wantErrContain: "error decoding", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v2/account/details", r.URL.Path) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + w.WriteHeader(tt.responseCode) + fmt.Fprint(w, tt.responseBody) + })) + defer srv.Close() + + tokenType, id, err := resolveTokenIdentity( + context.Background(), + srv.URL, + "/api/v2/", + "test-token", + ) + + if tt.wantErrContain != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrContain) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantTokenType, tokenType) + assert.Equal(t, tt.wantID, id) + }) + } +} From 9a8d3c1f36cb2471b6ff45f7cf18a594c4695ea7 Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 6 Mar 2026 11:07:54 -0500 Subject: [PATCH 12/13] reorder token saving and old token delete. use new token to delete old to prevent breaking config if new token is invalid --- path_config_rotate_root.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/path_config_rotate_root.go b/path_config_rotate_root.go index 1889dda..c791cd7 100644 --- a/path_config_rotate_root.go +++ b/path_config_rotate_root.go @@ -91,19 +91,27 @@ func (b *tfBackend) rotateRootToken(ctx context.Context, req *logical.Request) e config.Token, config.TokenID = newToken, newID if err := putConfigToStorage(ctx, req, config); err != nil { - b.Logger().Error("error saving new config after rotation: %v", err) + b.Logger().Error("error saving new config after rotation", "error", err) return fmt.Errorf("error saving new config after rotation: %w", err) } + // Reset the cached client so the next getClient call uses the new token. + b.reset() + + // Delete the old token using a fresh client authenticated with the new token. + // This is best-effort: the new token is already persisted and canonical, so + // a failure here should not fail the overall rotation. if config.OldToken == "delete" && (config.TokenType == "team" || config.TokenType == "user") { - if err := b.deleteToken(ctx, client, oldTokenID, config.TokenType); err != nil { - return fmt.Errorf("failed to delete old token: %w", err) + freshClient, err := b.getClient(ctx, req.Storage) + if err != nil { + b.Logger().Warn("failed to create client for old token cleanup", "error", err) + return nil + } + if err := b.deleteToken(ctx, freshClient, oldTokenID, config.TokenType); err != nil { + b.Logger().Warn("failed to delete old token after rotation", "old_token_id", oldTokenID, "error", err) } } - // reset the client so the next invocation will pick up the new configuration - b.reset() - return nil } From 01c9726367c0f953e9d06b60c045d210863c53a0 Mon Sep 17 00:00:00 2001 From: Drew Mullen Date: Fri, 6 Mar 2026 11:26:34 -0500 Subject: [PATCH 13/13] utilize testSystemView which implements DeregisterRotationJob --- path_credentials_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/path_credentials_test.go b/path_credentials_test.go index 0d619d4..7dccaf8 100644 --- a/path_credentials_test.go +++ b/path_credentials_test.go @@ -20,9 +20,11 @@ func newAcceptanceTestEnv() (*testEnv, error) { maxLease, _ := time.ParseDuration("60s") defaultLease, _ := time.ParseDuration("30s") conf := &logical.BackendConfig{ - System: &logical.StaticSystemView{ - DefaultLeaseTTLVal: defaultLease, - MaxLeaseTTLVal: maxLease, + System: &testSystemView{ + StaticSystemView: logical.StaticSystemView{ + DefaultLeaseTTLVal: defaultLease, + MaxLeaseTTLVal: maxLease, + }, }, Logger: logging.NewVaultLogger(log.Debug), }