Skip to content

Commit f6bf013

Browse files
authored
Merge pull request #144 from hashicorp/feature/User
Add user API support and current-user endpoints
2 parents be0394f + d0b826c commit f6bf013

5 files changed

Lines changed: 291 additions & 10 deletions

File tree

examples/user.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Example usage of the Users API.
2+
3+
This example demonstrates how to read a user by ID using the Python TFE SDK.
4+
"""
5+
6+
import os
7+
import sys
8+
9+
# Add the src directory to the Python path so we can import the local package.
10+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
11+
12+
from pytfe import TFEClient, TFEConfig
13+
14+
15+
def main() -> None:
16+
"""Read and print user details from Terraform Cloud."""
17+
user_id = os.getenv("TFE_USER_ID")
18+
19+
try:
20+
client = TFEClient(TFEConfig.from_env())
21+
22+
current_user = client.users.read_current()
23+
print("=== Current Terraform Cloud User ===")
24+
print(f"User ID: {current_user.id}")
25+
print(f"Username: {current_user.username}")
26+
print(f"Email: {current_user.email or 'N/A'}")
27+
print(f"Auth Method: {current_user.auth_method or 'N/A'}")
28+
29+
if not user_id:
30+
print("\nTFE_USER_ID not set. Skipping client.users.read(user_id).")
31+
return
32+
33+
user = client.users.read(user_id)
34+
35+
print("\n=== Terraform Cloud User By ID ===")
36+
print(f"User ID: {user.id}")
37+
print(f"Username: {user.username}")
38+
print(f"Email: {user.email or 'N/A'}")
39+
print(f"Auth Method: {user.auth_method or 'N/A'}")
40+
except Exception as e:
41+
print(f"Error running user example: {e}")
42+
43+
44+
if __name__ == "__main__":
45+
main()

src/pytfe/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from .resources.ssh_keys import SSHKeys
3737
from .resources.state_version_outputs import StateVersionOutputs
3838
from .resources.state_versions import StateVersions
39+
from .resources.user import Users
3940
from .resources.variable import Variables
4041
from .resources.variable_sets import VariableSets, VariableSetVariables
4142
from .resources.workspace_resources import WorkspaceResourcesService
@@ -73,6 +74,7 @@ def __init__(self, config: TFEConfig | None = None):
7374
self.plans = Plans(self._transport)
7475
self.organizations = Organizations(self._transport)
7576
self.organization_memberships = OrganizationMemberships(self._transport)
77+
self.users = Users(self._transport)
7678
self.organization_tokens = OrganizationTokens(self._transport)
7779
self.projects = Projects(self._transport)
7880
self.variables = Variables(self._transport)

src/pytfe/models/user.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,53 @@
11
# Copyright IBM Corp. 2025, 2026
22
# SPDX-License-Identifier: MPL-2.0
33

4-
from __future__ import annotations
5-
64
from pydantic import BaseModel, ConfigDict, Field
75

86

7+
class TwoFactor(BaseModel):
8+
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
9+
enabled: bool = Field(default=False, alias="enabled")
10+
verified: bool = Field(default=False, alias="verified")
11+
12+
13+
class UserPermissions(BaseModel):
14+
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
15+
16+
can_create_organizations: bool = Field(
17+
default=False, alias="can-create-organizations"
18+
)
19+
can_change_email: bool = Field(default=False, alias="can-change-email")
20+
can_change_username: bool = Field(default=False, alias="can-change-username")
21+
can_manage_user_tokens: bool = Field(default=False, alias="can-manage-user-tokens")
22+
can_view_2fa_settings: bool = Field(default=False, alias="can-view2fa-settings")
23+
can_manage_hcp_account: bool = Field(default=False, alias="can-manage-hcp-account")
24+
25+
926
class User(BaseModel):
1027
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
1128

1229
id: str = Field(..., alias="id")
13-
avatar_url: str = Field(default="", alias="avatar-url")
14-
email: str = Field(default="", alias="email")
30+
auth_method: str | None = Field(default=None, alias="auth-method")
31+
avatar_url: str | None = Field(default=None, alias="avatar-url")
32+
email: str | None = Field(default=None, alias="email")
1533
is_service_account: bool = Field(default=False, alias="is-service-account")
16-
two_factor: dict = Field(default_factory=dict, alias="two-factor")
17-
unconfirmed_email: str = Field(default="", alias="unconfirmed-email")
34+
two_factor: TwoFactor | None = Field(default=None, alias="two-factor")
35+
unconfirmed_email: str | None = Field(default=None, alias="unconfirmed-email")
1836
username: str = Field(default="", alias="username")
1937
v2_only: bool = Field(default=False, alias="v2-only")
20-
is_site_admin: bool = Field(default=False, alias="is-site-admin") # Deprecated
21-
is_admin: bool = Field(default=False, alias="is-admin")
22-
is_sso_login: bool = Field(default=False, alias="is-sso-login")
23-
permissions: dict = Field(default_factory=dict, alias="permissions")
38+
is_site_admin: bool | None = Field(
39+
default=None, alias="is-site-admin"
40+
) # Deprecated
41+
is_admin: bool | None = Field(default=None, alias="is-admin")
42+
is_sso_login: bool | None = Field(default=None, alias="is-sso-login")
43+
permissions: UserPermissions | None = Field(default=None, alias="permissions")
2444

2545
# Relations
2646
# authentication_tokens: AuthenticationTokens = Field(..., alias="authentication-tokens")
47+
48+
49+
class UserUpdateCurrentOptions(BaseModel):
50+
model_config = ConfigDict(populate_by_name=True, validate_by_name=True)
51+
52+
username: str | None = Field(default=None, alias="username")
53+
email: str | None = Field(default=None, alias="email")

src/pytfe/resources/user.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
from ..models.user import User, UserUpdateCurrentOptions
4+
from ..utils import valid_string_id
5+
from ._base import _Service
6+
7+
8+
class Users(_Service):
9+
def read(self, user_id: str) -> User:
10+
if not valid_string_id(user_id):
11+
raise ValueError("invalid user id")
12+
13+
r = self.t.request("GET", f"/api/v2/users/{user_id}")
14+
d = r.json()["data"]
15+
attr = d.get("attributes", {}) or {}
16+
user_data = dict(attr)
17+
user_data["id"] = d.get("id")
18+
return User(**user_data)
19+
20+
def read_current(self) -> User:
21+
r = self.t.request("GET", "/api/v2/account/details")
22+
d = r.json()["data"]
23+
attr = d.get("attributes", {}) or {}
24+
user_data = dict(attr)
25+
user_data["id"] = d.get("id")
26+
return User(**user_data)
27+
28+
def update_current(self, options: UserUpdateCurrentOptions) -> User:
29+
body = {
30+
"data": {
31+
"type": "users",
32+
"attributes": options.model_dump(exclude_none=True),
33+
}
34+
}
35+
r = self.t.request("PATCH", "/api/v2/account/update", json_body=body)
36+
d = r.json()["data"]
37+
attr = d.get("attributes", {}) or {}
38+
user_data = dict(attr)
39+
user_data["id"] = d.get("id")
40+
return User(**user_data)

tests/units/test_user.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Unit tests for the Users resource."""
2+
3+
import copy
4+
from unittest.mock import Mock
5+
6+
import pytest
7+
8+
from pytfe.models.user import User, UserPermissions, UserUpdateCurrentOptions
9+
from pytfe.resources.user import Users
10+
11+
12+
class TestUsers:
13+
"""Test suite for user resource operations."""
14+
15+
@pytest.fixture
16+
def mock_transport(self):
17+
"""Mock HTTP transport."""
18+
return Mock()
19+
20+
@pytest.fixture
21+
def users_service(self, mock_transport):
22+
"""Create users service with mocked transport."""
23+
return Users(mock_transport)
24+
25+
@pytest.fixture
26+
def sample_user_response(self):
27+
"""Sample JSON:API response for a user."""
28+
return {
29+
"data": {
30+
"id": "user-MA4GL63FmYRpSFxa",
31+
"type": "users",
32+
"attributes": {
33+
"username": "admin",
34+
"email": "admin@example.com",
35+
"is-service-account": False,
36+
"auth-method": "hcp_sso",
37+
"avatar-url": "https://example.com/avatar.png",
38+
"v2-only": True,
39+
"permissions": {
40+
"can-create-organizations": False,
41+
"can-change-email": True,
42+
"can-change-username": True,
43+
},
44+
},
45+
}
46+
}
47+
48+
def test_read_user(self, users_service, mock_transport, sample_user_response):
49+
"""Test reading a specific user by ID."""
50+
mock_transport.request.return_value.json.return_value = sample_user_response
51+
52+
user_id = "user-MA4GL63FmYRpSFxa"
53+
user = users_service.read(user_id)
54+
55+
mock_transport.request.assert_called_once_with(
56+
"GET", f"/api/v2/users/{user_id}"
57+
)
58+
assert isinstance(user, User)
59+
assert user.id == user_id
60+
assert user.username == "admin"
61+
assert user.email == "admin@example.com"
62+
assert user.is_service_account is False
63+
assert user.auth_method == "hcp_sso"
64+
assert user.avatar_url == "https://example.com/avatar.png"
65+
assert user.v2_only is True
66+
assert isinstance(user.permissions, UserPermissions)
67+
assert user.permissions is not None
68+
assert user.permissions.can_create_organizations is False
69+
assert user.permissions.can_change_email is True
70+
assert user.permissions.can_change_username is True
71+
assert user.permissions.can_manage_user_tokens is False
72+
assert user.permissions.can_view_2fa_settings is False
73+
assert user.permissions.can_manage_hcp_account is False
74+
75+
def test_read_user_invalid_id(self, users_service):
76+
"""Test reading a user with an invalid user ID."""
77+
with pytest.raises(ValueError, match="invalid user id"):
78+
users_service.read("")
79+
80+
def test_read_user_with_null_unconfirmed_email(
81+
self, users_service, mock_transport, sample_user_response
82+
):
83+
"""Test reading a user when unconfirmed-email is null."""
84+
sample_user_response["data"]["attributes"]["unconfirmed-email"] = None
85+
mock_transport.request.return_value.json.return_value = sample_user_response
86+
87+
user = users_service.read("user-MA4GL63FmYRpSFxa")
88+
89+
assert isinstance(user, User)
90+
assert user.unconfirmed_email is None
91+
92+
def test_read_user_two_factor_parsing(
93+
self, users_service, mock_transport, sample_user_response
94+
):
95+
"""Test reading a user with two-factor data."""
96+
modified_response = copy.deepcopy(sample_user_response)
97+
modified_response["data"]["attributes"]["two-factor"] = {
98+
"enabled": True,
99+
"verified": False,
100+
}
101+
mock_transport.request.return_value.json.return_value = modified_response
102+
103+
user_id = "user-MA4GL63FmYRpSFxa"
104+
user = users_service.read(user_id)
105+
106+
assert user.two_factor is not None
107+
assert user.two_factor.enabled is True
108+
assert user.two_factor.verified is False
109+
110+
def test_read_user_nullable_bools(
111+
self, users_service, mock_transport, sample_user_response
112+
):
113+
"""Test reading a user when pointer-style boolean fields are null."""
114+
modified_response = copy.deepcopy(sample_user_response)
115+
modified_response["data"]["attributes"]["is-site-admin"] = None
116+
modified_response["data"]["attributes"]["is-admin"] = None
117+
modified_response["data"]["attributes"]["is-sso-login"] = None
118+
mock_transport.request.return_value.json.return_value = modified_response
119+
120+
user_id = "user-MA4GL63FmYRpSFxa"
121+
user = users_service.read(user_id)
122+
123+
assert user.is_site_admin is None
124+
assert user.is_admin is None
125+
assert user.is_sso_login is None
126+
127+
def test_read_current_user(
128+
self, users_service, mock_transport, sample_user_response
129+
):
130+
"""Test reading the currently authenticated user."""
131+
mock_transport.request.return_value.json.return_value = sample_user_response
132+
133+
user = users_service.read_current()
134+
135+
mock_transport.request.assert_called_once_with("GET", "/api/v2/account/details")
136+
assert isinstance(user, User)
137+
assert user.id == "user-MA4GL63FmYRpSFxa"
138+
assert user.username == "admin"
139+
assert user.email == "admin@example.com"
140+
141+
def test_update_current_user(
142+
self, users_service, mock_transport, sample_user_response
143+
):
144+
"""Test updating the currently authenticated user."""
145+
mock_transport.request.return_value.json.return_value = sample_user_response
146+
options = UserUpdateCurrentOptions(
147+
username="new-admin",
148+
email="new-admin@example.com",
149+
)
150+
151+
user = users_service.update_current(options)
152+
153+
mock_transport.request.assert_called_once_with(
154+
"PATCH",
155+
"/api/v2/account/update",
156+
json_body={
157+
"data": {
158+
"type": "users",
159+
"attributes": {
160+
"username": "new-admin",
161+
"email": "new-admin@example.com",
162+
},
163+
}
164+
},
165+
)
166+
assert isinstance(user, User)
167+
assert user.id == "user-MA4GL63FmYRpSFxa"

0 commit comments

Comments
 (0)