Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9a4daf6

Browse files
authoredMay 12, 2021
Add impersonation APIs, add json kwarg to make_request (#46)
- ServerContext.make_request: payload is now optional - Updated usages that passed None to pass nothing - ServerContext.make_request: add json kwarg - This automatically does json_dumps and sets the content-type header for you - Updated usages of make_request to use the json kwarg where applicable - Add impersonate_user and stop_impersonating to security module (including APIWrapper) - Fix imports for mock in unit tests - Remove unused test_utils file. - Add more environment variables for integration test configuration - host, port, context_path can all be overridden via env vars
1 parent 3fc21ed commit 9a4daf6

16 files changed

+184
-141
lines changed
 

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ labkey\.egg-info/
1313

1414
.DS_Store
1515
.python-version
16+
.vscode/

‎CHANGE.txt

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ What's New in the LabKey 2.1.0 package
66
==============================
77

88
*Release date: TBD*
9-
Adding support for ontology based column filters ONTOLOGY_IN_SUBTREE and ONTOLOGY_NOT_IN_SUBTREE
9+
- Add support for ontology based column filters ONTOLOGY_IN_SUBTREE and ONTOLOGY_NOT_IN_SUBTREE
10+
- ServerContext.make_request: payload is now optional
11+
- ServerContext.make_request: add json kwarg
12+
- This automatically does json_dumps and sets the content-type header for you
13+
- Add impersonate_user and stop_impersonating to security module (including APIWrapper)
14+
- Add more environment variables for integration test configuration
15+
- host, port, and context_path can now be overridden via env vars
1016

1117
What's New in the LabKey 2.0.1 package
1218
==============================

‎labkey/container.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from .server_context import ServerContext
2-
from labkey.utils import json_dumps
32

43

54
def create(
@@ -23,7 +22,6 @@ def create(
2322
:param title: the title for the container.
2423
:return:
2524
"""
26-
headers = {"Content-Type": "application/json"}
2725
url = server_context.build_url("core", "createContainer.api", container_path)
2826
payload = {
2927
"description": description,
@@ -32,7 +30,7 @@ def create(
3230
"name": name,
3331
"title": title,
3432
}
35-
return server_context.make_request(url, json_dumps(payload), headers=headers)
33+
return server_context.make_request(url, json=payload)
3634

3735

3836
def delete(server_context: ServerContext, container_path: str = None) -> any:
@@ -44,7 +42,7 @@ def delete(server_context: ServerContext, container_path: str = None) -> any:
4442
"""
4543
headers = {"Content-Type": "application/json"}
4644
url = server_context.build_url("core", "deleteContainer.api", container_path)
47-
return server_context.make_request(url, None, headers=headers)
45+
return server_context.make_request(url, headers=headers)
4846

4947

5048
class ContainerWrapper:

‎labkey/domain.py

+4-8
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from typing import Union, List
1818

1919
from .server_context import ServerContext
20-
from labkey.utils import json_dumps
2120
from labkey.query import QueryFilter
2221

2322

@@ -390,13 +389,12 @@ def create(
390389
:return: Domain
391390
"""
392391
url = server_context.build_url("property", "createDomain.api", container_path=container_path)
393-
headers = {"Content-Type": "application/json"}
394392
domain = None
395393
domain_fields = domain_definition["domainDesign"]["fields"]
396394
domain_definition["domainDesign"]["fields"] = list(
397395
map(__format_conditional_filters, domain_fields)
398396
)
399-
raw_domain = server_context.make_request(url, json_dumps(domain_definition), headers=headers)
397+
raw_domain = server_context.make_request(url, json=domain_definition)
400398

401399
if raw_domain is not None:
402400
domain = Domain(**raw_domain)
@@ -416,10 +414,9 @@ def drop(
416414
:return:
417415
"""
418416
url = server_context.build_url("property", "deleteDomain.api", container_path=container_path)
419-
headers = {"Content-Type": "application/json"}
420417
payload = {"schemaName": schema_name, "queryName": query_name}
421418

422-
return server_context.make_request(url, json_dumps(payload), headers=headers)
419+
return server_context.make_request(url, json=payload)
423420

424421

425422
def get(
@@ -455,7 +452,7 @@ def infer_fields(
455452
:return:
456453
"""
457454
url = server_context.build_url("property", "inferDomain.api", container_path=container_path)
458-
raw_infer = server_context.make_request(url, None, file_payload={"inferfile": data_file})
455+
raw_infer = server_context.make_request(url, file_payload={"inferfile": data_file})
459456

460457
fields = None
461458
if "fields" in raw_infer:
@@ -483,14 +480,13 @@ def save(
483480
:return:
484481
"""
485482
url = server_context.build_url("property", "saveDomain.api", container_path=container_path)
486-
headers = {"Content-Type": "application/json"}
487483
payload = {
488484
"domainDesign": domain.to_json(),
489485
"queryName": query_name,
490486
"schemaName": schema_name,
491487
}
492488

493-
return server_context.make_request(url, json_dumps(payload), headers=headers)
489+
return server_context.make_request(url, json=payload)
494490

495491

496492
class DomainWrapper:

‎labkey/experiment.py

+3-12
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from typing import List, Optional
1818

1919
from .server_context import ServerContext
20-
from labkey.utils import json_dumps
2120

2221

2322
class ExpObject:
@@ -103,7 +102,7 @@ def to_json(self):
103102
data["materialOutputs"] = self.material_outputs
104103
data["plateMetadata"] = self.plate_metadata
105104

106-
# Issue 2489: Drop empty values. Server supplies default values for missing keys,
105+
# Issue 2489: Drop empty values. Server supplies default values for missing keys,
107106
# and will throw exception if a null value is supplied
108107
data = {k: v for k, v in data.items() if v}
109108
return data
@@ -163,14 +162,9 @@ def load_batch(server_context: ServerContext, assay_id: int, batch_id: int) -> O
163162
"""
164163
load_batch_url = server_context.build_url("assay", "getAssayBatch.api")
165164
loaded_batch = None
166-
167165
payload = {"assayId": assay_id, "batchId": batch_id}
166+
json_body = server_context.make_request(load_batch_url, json=payload)
168167

169-
headers = {"Content-type": "application/json", "Accept": "text/plain"}
170-
171-
json_body = server_context.make_request(
172-
load_batch_url, json_dumps(payload, sort_keys=True), headers=headers
173-
)
174168
if json_body is not None:
175169
loaded_batch = Batch(**json_body["batch"])
176170

@@ -215,11 +209,8 @@ def save_batches(
215209
raise Exception('save_batch() "batches" expected to be a set Batch instances')
216210

217211
payload = {"assayId": assay_id, "batches": json_batches}
218-
headers = {"Content-type": "application/json", "Accept": "text/plain"}
212+
json_body = server_context.make_request(save_batch_url, json=payload)
219213

220-
json_body = server_context.make_request(
221-
save_batch_url, json_dumps(payload, sort_keys=True), headers=headers
222-
)
223214
if json_body is not None:
224215
resp_batches = json_body["batches"]
225216
return [Batch(**resp_batch) for resp_batch in resp_batches]

‎labkey/query.py

+4-11
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,6 @@
4444
from typing import List
4545

4646
from .server_context import ServerContext
47-
from labkey.utils import json_dumps
48-
49-
_query_headers = {"Content-Type": "application/json"}
5047

5148
_default_timeout = 60 * 5 # 5 minutes
5249

@@ -176,8 +173,7 @@ def delete_rows(
176173

177174
return server_context.make_request(
178175
url,
179-
json_dumps(payload, sort_keys=True),
180-
headers=_query_headers,
176+
json=payload,
181177
timeout=timeout,
182178
)
183179

@@ -203,8 +199,7 @@ def truncate_table(
203199

204200
return server_context.make_request(
205201
url,
206-
json_dumps(payload, sort_keys=True),
207-
headers=_query_headers,
202+
json=payload,
208203
timeout=timeout,
209204
)
210205

@@ -294,8 +289,7 @@ def insert_rows(
294289

295290
return server_context.make_request(
296291
url,
297-
json_dumps(payload, sort_keys=True),
298-
headers=_query_headers,
292+
json=payload,
299293
timeout=timeout,
300294
)
301295

@@ -430,8 +424,7 @@ def update_rows(
430424

431425
return server_context.make_request(
432426
url,
433-
json_dumps(payload, sort_keys=True),
434-
headers=_query_headers,
427+
json=payload,
435428
timeout=timeout,
436429
)
437430

‎labkey/security.py

+76-4
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
# limitations under the License.
1515
#
1616
import functools
17+
from dataclasses import dataclass
18+
1719
from typing import Union, List
1820

1921
from labkey.server_context import ServerContext
2022

2123
SECURITY_CONTROLLER = "security"
2224
USER_CONTROLLER = "user"
25+
LOGIN_CONTROLLER = "login"
2326

2427

2528
def activate_users(
@@ -156,7 +159,7 @@ def get_roles(server_context: ServerContext, container_path: str = None):
156159
url = server_context.build_url(
157160
SECURITY_CONTROLLER, "getRoles.api", container_path=container_path
158161
)
159-
return server_context.make_request(url, None)
162+
return server_context.make_request(url)
160163

161164

162165
def get_user_by_email(server_context: ServerContext, email: str):
@@ -167,7 +170,7 @@ def get_user_by_email(server_context: ServerContext, email: str):
167170
:return:
168171
"""
169172
url = server_context.build_url(USER_CONTROLLER, "getUsers.api")
170-
payload = dict(includeDeactivatedAccounts=True)
173+
payload = {"includeDeactivatedAccounts": True}
171174
result = server_context.make_request(url, payload)
172175

173176
if result is None or result["users"] is None:
@@ -184,7 +187,6 @@ def list_groups(
184187
server_context: ServerContext, include_site_groups: bool = False, container_path: str = None
185188
):
186189
url = server_context.build_url(SECURITY_CONTROLLER, "listProjectGroups.api", container_path)
187-
188190
return server_context.make_request(url, {"includeSiteGroups": include_site_groups})
189191

190192

@@ -242,10 +244,68 @@ def reset_password(server_context: ServerContext, email: str, container_path: st
242244
:return:
243245
"""
244246
url = server_context.build_url(SECURITY_CONTROLLER, "adminRotatePassword.api", container_path)
245-
246247
return server_context.make_request(url, {"email": email})
247248

248249

250+
def impersonate_user(
251+
server_context: ServerContext,
252+
user_id: int = None,
253+
email: str = None,
254+
container_path: str = None,
255+
):
256+
"""
257+
For site-admins or project-admins only, start impersonating a user.
258+
259+
Admins may impersonate other users to perform actions on their behalf.
260+
Site users may impersonate any user in any project. Project admins must
261+
execute this command in a project in which they have admin permission
262+
and may impersonate any user that has access to the project.
263+
264+
To finish an impersonation session use `stop_impersonating`.
265+
266+
:param user_id: to impersonate (must supply this or email)
267+
:param email: to impersonate (must supply this or user_id)
268+
:param container_path: in which to impersonate the user
269+
"""
270+
if email is None and user_id is None:
271+
raise ValueError("Must supply either [email] or [user_id]")
272+
273+
url = server_context.build_url(USER_CONTROLLER, "impersonateUser.api", container_path)
274+
return server_context.make_request(url, {"userId": user_id, "email": email})
275+
276+
277+
def stop_impersonating(server_context: ServerContext):
278+
"""
279+
Stop impersonating a user while keeping the original user logged in.
280+
"""
281+
url = server_context.build_url(LOGIN_CONTROLLER, "stopImpersonating.api")
282+
return server_context.make_request(url)
283+
284+
285+
@dataclass
286+
class WhoAmI:
287+
id: int
288+
email: str
289+
display_name: str
290+
impersonated: str
291+
CSRF: str
292+
293+
294+
def who_am_i(server_context: ServerContext) -> WhoAmI:
295+
"""
296+
Calls the whoami API and returns a WhoAmI object.
297+
"""
298+
url = server_context.build_url("login", "whoami.api")
299+
response = server_context.make_request(url)
300+
return WhoAmI(
301+
response["id"],
302+
response["email"],
303+
response["displayName"],
304+
response["impersonated"],
305+
response["CSRF"],
306+
)
307+
308+
249309
def __make_security_group_api_request(
250310
server_context: ServerContext,
251311
api: str,
@@ -378,3 +438,15 @@ def remove_from_role(
378438
@functools.wraps(reset_password)
379439
def reset_password(self, email: str, container_path: str = None):
380440
return reset_password(self.server_context, email, container_path)
441+
442+
@functools.wraps(impersonate_user)
443+
def impersonate_user(self, user_id: int = None, email: str = None, container_path: str = None):
444+
return impersonate_user(self.server_context, user_id, email, container_path)
445+
446+
@functools.wraps(stop_impersonating)
447+
def stop_impersonating(self):
448+
return stop_impersonating(self.server_context)
449+
450+
@functools.wraps(who_am_i)
451+
def who_am_i(self):
452+
return who_am_i(self.server_context)

‎labkey/server_context.py

+22-15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from labkey.utils import json_dumps
12
import requests
23
from requests.exceptions import RequestException
34
from labkey.exceptions import (
@@ -106,44 +107,50 @@ def handle_request_exception(self, exception):
106107
def make_request(
107108
self,
108109
url: str,
109-
payload: any,
110+
payload: any = None,
110111
headers: dict = None,
111112
timeout: int = 300,
112113
method: str = "POST",
113114
non_json_response: bool = False,
114115
file_payload: any = None,
116+
json: dict = None,
115117
) -> any:
116118
if self._api_key is not None:
117119
if self._session.headers.get(API_KEY_TOKEN) is not self._api_key:
118120
self._session.headers.update({API_KEY_TOKEN: self._api_key})
119121

120-
if not self._disable_csrf:
121-
if CSRF_TOKEN not in self._session.headers.keys():
122-
try:
123-
csrf_url = self.build_url("login", "whoami.api")
124-
response = handle_response(self._session.get(csrf_url))
125-
self._session.headers.update({CSRF_TOKEN: response["CSRF"]})
126-
except RequestException as e:
127-
self.handle_request_exception(e)
122+
if not self._disable_csrf and CSRF_TOKEN not in self._session.headers.keys():
123+
try:
124+
csrf_url = self.build_url("login", "whoami.api")
125+
response = handle_response(self._session.get(csrf_url))
126+
self._session.headers.update({CSRF_TOKEN: response["CSRF"]})
127+
except RequestException as e:
128+
self.handle_request_exception(e)
128129

129130
try:
130131
if method == "GET":
131-
raw_response = self._session.get(
132-
url, params=payload, headers=headers, timeout=timeout
133-
)
132+
response = self._session.get(url, params=payload, headers=headers, timeout=timeout)
134133
else:
135134
if file_payload is not None:
136-
raw_response = self._session.post(
135+
response = self._session.post(
137136
url,
138137
data=payload,
139138
files=file_payload,
140139
headers=headers,
141140
timeout=timeout,
142141
)
142+
elif json is not None:
143+
if headers is None:
144+
headers = {}
145+
146+
headers_ = {**headers, "Content-Type": "application/json"}
147+
# sort_keys is a hack to make unit tests work
148+
data = json_dumps(json, sort_keys=True)
149+
response = self._session.post(url, data=data, headers=headers_, timeout=timeout)
143150
else:
144-
raw_response = self._session.post(
151+
response = self._session.post(
145152
url, data=payload, headers=headers, timeout=timeout
146153
)
147-
return handle_response(raw_response, non_json_response)
154+
return handle_response(response, non_json_response)
148155
except RequestException as e:
149156
self.handle_request_exception(e)

‎test/integration/conftest.py

+6-4
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
@pytest.fixture(scope="session")
1818
def server_context_vars():
1919
properties_file_path = os.getenv("TEAMCITY_BUILD_PROPERTIES_FILE")
20-
host = DEFAULT_HOST
21-
port = DEFAULT_PORT
22-
context_path = DEFAULT_CONTEXT_PATH
20+
host = os.getenv("HOST", DEFAULT_HOST)
21+
port = os.getenv("PORT", DEFAULT_PORT)
22+
context_path = os.getenv("CONTEXT_PATH", DEFAULT_CONTEXT_PATH)
2323

2424
if properties_file_path is not None:
2525
with open(properties_file_path) as f:
@@ -45,7 +45,9 @@ def server_context_vars():
4545
@pytest.fixture(scope="session")
4646
def api(server_context_vars):
4747
server, context_path = server_context_vars
48-
return APIWrapper(server, PROJECT_NAME, context_path, use_ssl=False)
48+
api = APIWrapper(server, PROJECT_NAME, context_path, use_ssl=False)
49+
api.security.stop_impersonating() # Call stop impersonating incase previous test run failed while impersonating a user.
50+
return api
4951

5052

5153
@pytest.fixture(autouse=True, scope="session")

‎test/integration/test_security.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from labkey.security import who_am_i
2+
import pytest
3+
from labkey.api_wrapper import APIWrapper
4+
5+
# copy from:
6+
# JavaClientApiTest.testImpersonateUser()
7+
# JavaClientApiTest.testImpersonationConnection()
8+
9+
pytestmark = pytest.mark.integration # Mark all tests in this module as integration tests
10+
TEST_EMAIL = "test_user@test.test"
11+
TEST_DISPLAY_NAME = "test user"
12+
13+
14+
@pytest.fixture(scope="session")
15+
def test_user(api: APIWrapper, project):
16+
url = api.server_context.build_url("security", "createNewUser.api")
17+
resp = api.server_context.make_request(url, {"email": TEST_EMAIL, "sendEmail": False})
18+
user_id = resp["userId"]
19+
yield {"id": user_id, "email": TEST_EMAIL, "display_name": TEST_DISPLAY_NAME}
20+
url = api.server_context.build_url("security", "deleteUser.api", container_path="/")
21+
resp = api.server_context.make_request(url, {"id": user_id})
22+
23+
24+
def test_impersonation(api: APIWrapper, test_user):
25+
# test impersonation via email
26+
api.security.impersonate_user(email=TEST_EMAIL)
27+
who = who_am_i(api.server_context)
28+
assert who.display_name == test_user["display_name"]
29+
assert who.email == test_user["email"]
30+
assert who.id == test_user["id"]
31+
32+
# test stop impersonating
33+
api.security.stop_impersonating()
34+
who = who_am_i(api.server_context)
35+
assert who.display_name != test_user["display_name"]
36+
assert who.email != test_user["email"]
37+
assert who.id != test_user["id"]
38+
39+
# test impersonation via user id
40+
api.security.impersonate_user(user_id=test_user["id"])
41+
who = who_am_i(api.server_context)
42+
assert who.display_name == test_user["display_name"]
43+
assert who.email == test_user["email"]
44+
assert who.id == test_user["id"]
45+
46+
# We need to stop impersonating a user before leaving so we don't mess up other tests.
47+
api.security.stop_impersonating()

‎test/unit/test_domain.py

+6-9
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@
1818
import tempfile
1919
import unittest
2020

21-
try:
22-
import mock
23-
except ImportError:
24-
import unittest.mock as mock
21+
import unittest.mock as mock
2522

2623
from labkey.domain import (
2724
create,
@@ -70,7 +67,7 @@ class MockCreate(MockLabKey):
7067

7168
self.expected_kwargs = {
7269
"expected_args": [self.service.get_server_url()],
73-
"data": json.dumps(domain_definition),
70+
"data": json.dumps(domain_definition, sort_keys=True),
7471
"headers": {"Content-Type": "application/json"},
7572
"timeout": 300,
7673
}
@@ -116,7 +113,7 @@ class MockDrop(MockLabKey):
116113

117114
self.expected_kwargs = {
118115
"expected_args": [self.service.get_server_url()],
119-
"data": json.dumps(payload),
116+
"data": json.dumps(payload, sort_keys=True),
120117
"headers": {"Content-Type": "application/json"},
121118
"timeout": 300,
122119
}
@@ -275,7 +272,7 @@ class MockSave(MockLabKey):
275272

276273
self.expected_kwargs = {
277274
"expected_args": [self.service.get_server_url()],
278-
"data": json.dumps(payload),
275+
"data": json.dumps(payload, sort_keys=True),
279276
"headers": {"Content-Type": "application/json"},
280277
"timeout": 300,
281278
}
@@ -347,7 +344,7 @@ class MockCreate(MockLabKey):
347344

348345
self.expected_kwargs = {
349346
"expected_args": [self.service.get_server_url()],
350-
"data": json.dumps(self.domain_definition),
347+
"data": json.dumps(self.domain_definition, sort_keys=True),
351348
"headers": {"Content-Type": "application/json"},
352349
"timeout": 300,
353350
}
@@ -431,7 +428,7 @@ class MockSave(MockLabKey):
431428

432429
self.expected_kwargs = {
433430
"expected_args": [self.service.get_server_url()],
434-
"data": json.dumps(payload),
431+
"data": json.dumps(payload, sort_keys=True),
435432
"headers": {"Content-Type": "application/json"},
436433
"timeout": 300,
437434
}

‎test/unit/test_experiment_api.py

+3-6
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@
1515
#
1616
import unittest
1717

18-
try:
19-
import mock
20-
except ImportError:
21-
import unittest.mock as mock
18+
import unittest.mock as mock
2219

2320
from labkey.experiment import load_batch, save_batch, Batch, Run
2421
from labkey.exceptions import (
@@ -209,7 +206,7 @@ def setUp(self):
209206
self.expected_kwargs = {
210207
"expected_args": [self.service.get_server_url()],
211208
"data": '{"assayId": 12345, "batchId": 54321}',
212-
"headers": {"Content-type": "application/json", "Accept": "text/plain"},
209+
"headers": {"Content-Type": "application/json"},
213210
"timeout": 300,
214211
}
215212

@@ -291,7 +288,7 @@ def setUp(self):
291288
self.expected_kwargs = {
292289
"expected_args": [self.service.get_server_url()],
293290
"data": '{"assayId": 12345, "batches": [{"batchProtocolId": null, "comment": null, "created": null, "createdBy": null, "modified": null, "modifiedBy": null, "name": null, "properties": {"PropertyName": "Property Value"}, "runs": [{"name": "python upload", "properties": {"RunFieldName": "Run Field Value"}}]}]}',
294-
"headers": {"Content-type": "application/json", "Accept": "text/plain"},
291+
"headers": {"Content-Type": "application/json"},
295292
"timeout": 300,
296293
}
297294

‎test/unit/test_query_api.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@
1515
#
1616
import unittest
1717

18-
try:
19-
import mock
20-
except ImportError:
21-
import unittest.mock as mock
18+
import unittest.mock as mock
2219

2320
from labkey.query import (
2421
delete_rows,

‎test/unit/test_security.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@
1515
#
1616
import unittest
1717

18-
try:
19-
import mock
20-
except ImportError:
21-
import unittest.mock as mock
18+
import unittest.mock as mock
2219

2320
from labkey.security import (
2421
create_user,

‎test/unit/test_utils.py

-55
This file was deleted.

‎test/unit/utilities.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@
1717

1818
from labkey.server_context import ServerContext
1919

20-
try:
21-
import mock
22-
except ImportError:
23-
import unittest.mock as mock
20+
import unittest.mock as mock
2421

2522

2623
def mock_server_context(mock_action):

0 commit comments

Comments
 (0)
Please sign in to comment.