Skip to content

Commit 2ec3c00

Browse files
krowvinEnovotny
andauthored
Add ALL Methods for Users Endpoints (#268)
* Create users store/update user methods and expose them * Create mock and CDA tests * Create notebook example for users methods * Remove user mock test * Add other methods to users and make it more clear what the .get method is doing * Allow the tests to pull our API keys and have the defaults from the users.sql baked in * - Update test to include other methods - q0hectest key works for normal data, but user-management endpoints require CWMS User Admins; l2hectest is a better default for these tests * web perms container was not running because DB Healthcheck did not have a host/port * Ensure files save as a specific end of line per the editor config * If editor does not support saving with a specific eol, make sure it commits with that eol * Ensure apikey does not bleed out into any stdouts * Didn't mean to merge other branch in! * Comment the roles so the coverage is more clear * Add additional error types and make sure a message can be passed * Capture the various error codes and wrap with a friendlier user message * Give q0hectest admin over LRL, SPK, and MVP for users tests * add a utility method for user error management * Allow options for admin vs nonadmin api key env imports * Add a test for friendly roles and a test to ensure roles being set, confirm admin role * Fix typing error on user profile response * Add a one-off user for a forbidden lookup test * Remove debug statement with api key present * Remove the other printout of the API key * Fix new line in the docker compose * Update docker-compose.yml * Update 403 test key to non-admin user * Patch bump --------- Co-authored-by: Eric Novotny <eric.v.novotny@usace.army.mil>
1 parent 190bbc6 commit 2ec3c00

8 files changed

Lines changed: 634 additions & 149 deletions

File tree

compose_files/sql/users.sql

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,26 @@ begin
3333
cwms_sec.add_user_to_group('q0hectest','All Users', 'LRL');
3434
cwms_sec.add_user_to_group('q0hectest','CWMS Users', 'LRL');
3535
cwms_sec.add_user_to_group('q0hectest','TS ID Creator','LRL');
36+
cwms_sec.add_user_to_group('q0hectest','CWMS User Admins', 'LRL');
3637

3738
cwms_sec.add_cwms_user('q0hectest', NULL, 'SPK');
3839
cwms_sec.add_user_to_group('q0hectest','All Users', 'SPK');
3940
cwms_sec.add_user_to_group('q0hectest','CWMS Users', 'SPK');
4041
cwms_sec.add_user_to_group('q0hectest','TS ID Creator','SPK');
42+
cwms_sec.add_user_to_group('q0hectest','CWMS User Admins', 'SPK');
4143

4244
cwms_sec.add_cwms_user('q0hectest', NULL, 'MVP');
4345
cwms_sec.add_user_to_group('q0hectest','All Users', 'MVP');
4446
cwms_sec.add_user_to_group('q0hectest','CWMS Users', 'MVP');
4547
cwms_sec.add_user_to_group('q0hectest','TS ID Creator','MVP');
48+
cwms_sec.add_user_to_group('q0hectest','CWMS User Admins', 'MVP');
4649

4750
insert into cwms_20.at_api_keys (userid, key_name, apikey) values ('Q0HECTEST', 'testkey', '0123456789abcdef0123456789abcdef');
48-
4951
insert into cwms_20.at_api_keys (userid, key_name, apikey) values ('L2HECTEST', 'testkey2', '1234567890abcdef1234567890abcdef');
52+
-- Non-admin API key for the L1 with reduced permissions
53+
-- Used by CDA user-management tests that verify 403 handling
54+
insert into cwms_20.at_api_keys (userid, key_name, apikey) values ('L1HECTEST', 'non_admin_test_key', 'fedcba9876543210fedcba9876543210');
5055

5156
end;
5257
/
53-
quit;
58+
quit;

cwms/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from cwms.timeseries.timeseries_profile_parser import *
3232
from cwms.timeseries.timeseries_txt import *
3333
from cwms.turbines.turbines import *
34+
from cwms.users.users import *
3435

3536
try:
3637
__version__ = version("cwms-python")

cwms/api.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,14 @@ class ApiError(Exception):
7878
a concise, single-line error message with an optional hint.
7979
"""
8080

81-
def __init__(self, response: Response):
81+
def __init__(self, response: Response, message: Optional[str] = None):
8282
self.response = response
83+
self.message = message
8384

8485
def __str__(self) -> str:
86+
if self.message:
87+
return self.message
88+
8589
# Include the request URL in the error message.
8690
message = f"CWMS API Error ({self.response.url})"
8791

@@ -125,6 +129,14 @@ def hint(self) -> str:
125129
return ""
126130

127131

132+
class NotFoundError(ApiError):
133+
"""Raised when a requested CDA resource does not exist."""
134+
135+
136+
class PermissionError(ApiError):
137+
"""Raised when the CDA request is not authorized for the current caller."""
138+
139+
128140
def init_session(
129141
*,
130142
api_root: Optional[str] = None,
@@ -160,7 +172,6 @@ def init_session(
160172
if api_key:
161173
if api_key.startswith("apikey "):
162174
api_key = api_key.replace("apikey ", "")
163-
logging.debug(f"Setting authorization key: api_key={api_key}")
164175
SESSION.headers.update({"Authorization": "apikey " + api_key})
165176

166177
return SESSION

cwms/users/users.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import json
2+
from typing import Any, List, Optional
3+
4+
import cwms.api as api
5+
from cwms.cwms_types import Data
6+
7+
8+
def _raise_user_management_error(error: api.ApiError, action: str) -> None:
9+
status_code = getattr(error.response, "status_code", None)
10+
if status_code == 403:
11+
response_hint = getattr(error.response, "reason", None) or "Forbidden"
12+
message = (
13+
f"{action} could not be completed because the current credentials "
14+
"are not authorized for user-management access or are missing the "
15+
f"required role assignment. CDA responded with 403 {response_hint}."
16+
)
17+
raise api.PermissionError(error.response, message) from None
18+
raise error
19+
20+
21+
def get_roles() -> List[str]:
22+
"""Retrieve all available user-management roles."""
23+
24+
try:
25+
response = api.get("roles", api_version=1)
26+
except api.ApiError as error:
27+
_raise_user_management_error(error, "User role lookup")
28+
return list(response)
29+
30+
31+
def get_user_profile() -> dict[str, Any]:
32+
"""Retrieve the profile for the currently authenticated user."""
33+
34+
response = api.get("user/profile", api_version=1)
35+
return dict(response)
36+
37+
38+
def get_users(
39+
office_id: Optional[str] = None,
40+
page: Optional[str] = None,
41+
page_size: Optional[int] = None,
42+
) -> Data:
43+
"""Retrieve users with optional office and paging filters."""
44+
45+
params = {"office": office_id, "page": page, "page-size": page_size}
46+
try:
47+
response = api.get("users", params=params, api_version=1)
48+
except api.ApiError as error:
49+
_raise_user_management_error(error, "User list lookup")
50+
return Data(response, selector="users")
51+
52+
53+
def get_user(user_name: str) -> dict[str, Any]:
54+
"""Retrieve a single user by user name."""
55+
56+
if not user_name:
57+
raise ValueError("Get user requires a user name")
58+
try:
59+
response = api.get(f"users/{user_name}", api_version=1)
60+
except api.ApiError as error:
61+
status_code = getattr(error.response, "status_code", None)
62+
if status_code == 404:
63+
raise api.NotFoundError(
64+
error.response, f"User '{user_name}' was not found."
65+
) from None
66+
if status_code == 403:
67+
_raise_user_management_error(error, f"User '{user_name}' retrieval")
68+
raise
69+
return dict(response)
70+
71+
72+
def store_user(user_name: str, office_id: str, roles: List[str]) -> None:
73+
"""Create a user role assignment for an office.
74+
75+
Notes
76+
-----
77+
The CDA User Management API creates/manages user access through role assignment
78+
at `/user/{user-name}/roles/{office-id}`.
79+
"""
80+
81+
if not user_name:
82+
raise ValueError("Store user requires a user name")
83+
if not office_id:
84+
raise ValueError("Store user requires an office id")
85+
if not roles:
86+
raise ValueError("Store user requires a roles list")
87+
88+
endpoint = f"user/{user_name}/roles/{office_id}"
89+
try:
90+
api.post(endpoint, roles)
91+
except api.ApiError as error:
92+
_raise_user_management_error(
93+
error, f"User '{user_name}' role assignment update"
94+
)
95+
96+
97+
def delete_user_roles(user_name: str, office_id: str, roles: List[str]) -> None:
98+
"""Delete user role assignments for an office."""
99+
100+
if not user_name:
101+
raise ValueError("Delete user roles requires a user name")
102+
if not office_id:
103+
raise ValueError("Delete user roles requires an office id")
104+
if roles is None:
105+
raise ValueError("Delete user roles requires a roles list")
106+
107+
endpoint = f"user/{user_name}/roles/{office_id}"
108+
headers = {"accept": "*/*", "Content-Type": api.api_version_text(api.API_VERSION)}
109+
# TODO: Delete does not currently support a body in the api module. Use SESSION directly
110+
with api.SESSION.delete(
111+
endpoint, headers=headers, data=json.dumps(roles)
112+
) as response:
113+
if not response.ok:
114+
_raise_user_management_error(
115+
api.ApiError(response), f"User '{user_name}' role deletion"
116+
)
117+
118+
119+
def update_user(user_name: str, office_id: str, roles: List[str]) -> None:
120+
"""Update a user's roles for an office by replacing the current role set."""
121+
122+
if not user_name:
123+
raise ValueError("Update user requires a user name")
124+
if not office_id:
125+
raise ValueError("Update user requires an office id")
126+
if not roles:
127+
raise ValueError("Update user requires a roles list")
128+
129+
endpoint = f"user/{user_name}/roles/{office_id}"
130+
user = get_user(user_name)
131+
132+
roles_by_office = user.get("roles")
133+
if isinstance(roles_by_office, dict):
134+
existing_roles = roles_by_office.get(office_id, [])
135+
elif isinstance(roles_by_office, list):
136+
existing_roles = roles_by_office
137+
else:
138+
existing_roles = []
139+
140+
if not isinstance(existing_roles, list):
141+
existing_roles = []
142+
143+
desired_roles = sorted(set(roles))
144+
current_roles = sorted(set(existing_roles))
145+
# Determine roles to add and remove
146+
roles_to_remove = [role for role in current_roles if role not in desired_roles]
147+
roles_to_add = [role for role in desired_roles if role not in current_roles]
148+
149+
if roles_to_remove:
150+
delete_user_roles(user_name, office_id, roles_to_remove)
151+
if roles_to_add:
152+
try:
153+
api.post(endpoint, roles_to_add)
154+
except api.ApiError as error:
155+
_raise_user_management_error(error, f"User '{user_name}' role replacement")

0 commit comments

Comments
 (0)