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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ bin
tmp
.idea
.vscode
.DS_Store
config.json
.bin
95 changes: 95 additions & 0 deletions account_details.go
Original file line number Diff line number Diff line change
@@ -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"`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
} `json:"relationships"`
} `json:"relationships"`
Links struct {
Self string `json:"self"`
AuthToken string `json:"auth-token",omitempty"`
} `json:"links"`

Copy link
Copy Markdown
Contributor Author

@drewmullen drewmullen Apr 24, 2026

Choose a reason for hiding this comment

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

because AuthToken may not come from older versions of tfe the omitempty may not work here. may have to rework the struct definition

} `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-<orgname>-<random>"
// 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
}
140 changes: 140 additions & 0 deletions account_details_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
13 changes: 10 additions & 3 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -51,12 +54,16 @@ func backend() *tfBackend {
pathCredentials(&b),
},
pathRotateRole(&b),
pathConfigRotate(&b),
),
Secrets: []*framework.Secret{
b.terraformToken(),
},
BackendType: logical.TypeLogical,
Invalidate: b.invalidate,
RotateCredential: func(ctx context.Context, req *logical.Request) error {
return b.rotateRootToken(ctx, req)
},
}

return &b
Expand Down
24 changes: 17 additions & 7 deletions backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const (
envVarTerraformTeamID = "TF_TEAM_ID"
envVarTerraformUserID = "TF_USER_ID"
envVarTerraformAddress = "TF_ADDRESS"
// Rotation environment variables
envVarTerraformTokenID = "TF_TOKEN_ID"
)

func getTestBackend(tb testing.TB) (*tfBackend, logical.Storage) {
Expand All @@ -28,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"
Expand All @@ -59,13 +67,15 @@ type testEnv struct {
}

func (e *testEnv) AddConfig(t *testing.T) {
data := map[string]interface{}{
"token": e.Token,
}

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)
Expand Down Expand Up @@ -119,8 +129,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)
Expand Down
Loading