Skip to content

Commit 8c4171a

Browse files
committed
Merge branch 'master' into bcwu-excludefiles
2 parents 20457d6 + 235019d commit 8c4171a

26 files changed

+1365
-6
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
.DS_Store
33
.coverage
44
.vagrant
5+
/.vscode/
56
/*.egg
67
/*.egg-info
78
/*.eggs

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [1.12.0] - 2022-10-26
8+
9+
### Added
10+
- You can now use the new rsconnect bootstrap command to programmatically provision an initial administrator api key on a fresh Connect instance. This requires RStudio Connect release 2022.10.0 or later and Python version >= 3.6.
11+
712
## [1.11.0] - 2022-10-12
813

914
### Added

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,6 @@ Cannot find compatible environment: no compatible Local environment with Python
863863
Task failed. Task exited with status 1.
864864
```
865865
866-
867866
## Common Usage Examples
868867
869868
### Searching for content
@@ -969,3 +968,19 @@ rsconnect content search --published | jq '.[].guid' > guids.txt
969968
# bulk-add from the guids.txt by executing a single `rsconnect content build add` command
970969
xargs printf -- '-g %s\n' < guids.txt | xargs rsconnect content build add
971970
```
971+
## Programmatic Provisioning
972+
973+
RStudio Connect supports the programmatic bootstrapping of an admininistrator API key
974+
for scripted provisioning tasks. This process is supported by the `rsconnect bootstrap` command,
975+
which uses a JSON Web Token to request an initial API key from a fresh Connect instance.
976+
977+
!!! warning
978+
979+
This feature **requires Python version 3.6 or higher**.
980+
981+
```bash
982+
$ rsconnect bootstrap --server https://connect.example.org:3939 --jwt-keypath /path/to/secret.key
983+
```
984+
985+
A full description on how to use `rsconnect bootstrap` in a provisioning workflow is provided in the Connect administrator guide's
986+
[programmatic provisioning](https://docs.rstudio.com/connect/admin/programmatic-provisioning) documentation.

integration/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
env.json

integration/__init__.py

Whitespace-only changes.

integration/jwt_testbed.py

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import requests
2+
import time
3+
import sys
4+
import json
5+
from datetime import timedelta
6+
7+
from rsconnect.json_web_token import (
8+
JWTEncoder,
9+
TokenGenerator,
10+
read_secret_key,
11+
validate_hs256_secret_key,
12+
DEFAULT_ISSUER,
13+
DEFAULT_AUDIENCE,
14+
BOOTSTRAP_SCOPE,
15+
BOOTSTRAP_EXP,
16+
)
17+
18+
BOOTSTRAP_ENDPOINT = "/__api__/v1/experimental/bootstrap"
19+
API_KEY_ENDPOINT = "/__api__/me"
20+
21+
22+
ENV_FILENAME = "env.json"
23+
24+
SUCCESS = "\u2713"
25+
FAILURE = "FAILED"
26+
27+
28+
def read_env():
29+
with open("integration/env.json", "r") as f:
30+
return json.loads(f.read())
31+
32+
33+
def preamble(step, text):
34+
print("[{}] {}... ".format(step, text), end="")
35+
36+
37+
def success():
38+
print(SUCCESS)
39+
40+
41+
def failure(reason):
42+
print(FAILURE + ": {}".format(reason))
43+
sys.exit(1)
44+
45+
46+
def api_key_authorization_header(token):
47+
return {"Authorization": "Key " + token}
48+
49+
50+
def jwt_authorization_header(token):
51+
return {"Authorization": "Connect-Bootstrap " + token}
52+
53+
54+
def generate_jwt_secured_header(keypath):
55+
secret_key = read_secret_key(keypath)
56+
validate_hs256_secret_key(secret_key)
57+
58+
token_generator = TokenGenerator(secret_key)
59+
60+
initial_admin_token = token_generator.bootstrap()
61+
return jwt_authorization_header(initial_admin_token)
62+
63+
64+
def create_jwt_encoder(keypath, issuer, audience):
65+
secret_key = read_secret_key(keypath)
66+
validate_hs256_secret_key(secret_key)
67+
68+
return JWTEncoder(issuer, audience, secret_key)
69+
70+
71+
def assert_status_code(response, expected):
72+
if response.status_code != expected:
73+
failure("unexpected response status: " + str(response.status_code))
74+
75+
76+
def no_header(step, env):
77+
preamble(step, "Unable to access endpoint without a header present")
78+
79+
response = requests.post(env["bootstrap_endpoint"])
80+
81+
assert_status_code(response, 401)
82+
83+
success()
84+
85+
86+
def no_jwt_header(step, env):
87+
preamble(step, "Unable to access endpoint without a JWT in the auth header")
88+
89+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(""))
90+
91+
assert_status_code(response, 401)
92+
93+
success()
94+
95+
96+
def invalid_jwt_header(step, env):
97+
preamble(step, "Unable to access endpoint with a bearer token that isn't a JWT")
98+
99+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header("invalid"))
100+
101+
assert_status_code(response, 401)
102+
103+
success()
104+
105+
106+
def incorrect_jwt_invalid_issuer(step, env):
107+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid issuer)")
108+
109+
encoder = create_jwt_encoder(env["keypath"], "invalid", DEFAULT_AUDIENCE)
110+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
111+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
112+
113+
assert_status_code(response, 401)
114+
115+
success()
116+
117+
118+
def incorrect_jwt_invalid_audience(step, env):
119+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid audience)")
120+
121+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, "invalid")
122+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
123+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
124+
125+
assert_status_code(response, 401)
126+
127+
success()
128+
129+
130+
def incorrect_jwt_invalid_scope(step, env):
131+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (invalid scope)")
132+
133+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE)
134+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
135+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
136+
137+
assert_status_code(response, 401)
138+
139+
success()
140+
141+
142+
def no_scope(step, env):
143+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (no scope provided)")
144+
145+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE)
146+
token = encoder.new_token({"invalid": "invalid"}, BOOTSTRAP_EXP)
147+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
148+
149+
assert_status_code(response, 401)
150+
151+
success()
152+
153+
154+
def different_secret(step, env):
155+
preamble(step, "Unable to access endpoint with a JWT signed with an unexpected secret")
156+
157+
encoder = JWTEncoder(DEFAULT_ISSUER, DEFAULT_AUDIENCE, "invalid_secret")
158+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, BOOTSTRAP_EXP)
159+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
160+
161+
assert_status_code(response, 401)
162+
163+
success()
164+
165+
166+
def incorrect_jwt_expired(step, env):
167+
preamble(step, "Unable to access endpoint with an incorrectly scoped JWT (expired)")
168+
169+
encoder = create_jwt_encoder(env["keypath"], DEFAULT_ISSUER, DEFAULT_AUDIENCE)
170+
token = encoder.new_token({"scope": BOOTSTRAP_SCOPE}, timedelta(seconds=1))
171+
time.sleep(5)
172+
response = requests.post(env["bootstrap_endpoint"], jwt_authorization_header(token))
173+
174+
assert_status_code(response, 401)
175+
176+
success()
177+
178+
179+
def verify_api_key_endpoint_invalid(step, env):
180+
preamble(step, "Unable to access api key endpoint with invalid api key (prereq)")
181+
182+
response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header("invalid"))
183+
assert_status_code(response, 401)
184+
185+
empty_string_response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header(""))
186+
assert_status_code(empty_string_response, 401)
187+
188+
success()
189+
190+
191+
def verify_api_key_endpoint_empty(step, env):
192+
preamble(step, "Unable to access api key endpoint with no api key (prereq)")
193+
194+
response = requests.get(env["api_key_endpoint"])
195+
assert_status_code(response, 401)
196+
197+
success()
198+
199+
200+
def endpoint_happy_path(step, env):
201+
preamble(step, "Verifying initial admin endpoint happy path")
202+
203+
response = requests.post(env["bootstrap_endpoint"], headers=generate_jwt_secured_header(env["keypath"]))
204+
205+
assert_status_code(response, 200)
206+
207+
json_data = response.json()
208+
if "api_key" not in json_data:
209+
failure("api_key key not in json response")
210+
211+
api_key = json_data["api_key"]
212+
if api_key is None or api_key == "":
213+
failure("api_key value not in json response")
214+
215+
# verify that we can get into the api key endpoint with the returned key
216+
217+
api_key = json_data["api_key"]
218+
219+
api_response = requests.get(env["api_key_endpoint"], headers=api_key_authorization_header(api_key))
220+
assert_status_code(api_response, 200)
221+
api_json = api_response.json()
222+
223+
# verify that the response is reasonable from an api_key secured endpoint
224+
225+
if "username" not in api_json:
226+
failure("No username returned from /me")
227+
228+
if len(api_json["username"]) == 0:
229+
failure("Empty username returned from /me")
230+
231+
if "user_role" not in api_json:
232+
failure("No user_role returned from /me")
233+
234+
if api_json["user_role"] != "administrator":
235+
failure("Invalid user_role returned from /me: {}".format(api_json["user_role"]))
236+
237+
# bootstrap endpoint should not respond to api key
238+
bootstrap_api_response = requests.post(env["bootstrap_endpoint"], headers=jwt_authorization_header(api_key))
239+
assert_status_code(bootstrap_api_response, 401)
240+
241+
success()
242+
243+
244+
def endpoint_subsequent_calls(step, env):
245+
preamble(step, "Subsequent call should fail gracefully")
246+
247+
response = requests.post(env["bootstrap_endpoint"], headers=generate_jwt_secured_header(env["keypath"]))
248+
249+
assert_status_code(response, 403)
250+
251+
success()
252+
253+
254+
def other_endpoint_does_not_accept_jwts(step, env):
255+
preamble(step, "Only the initial admin endpoint will authorize using jwts")
256+
257+
response = requests.get(env["api_key_endpoint"], headers=generate_jwt_secured_header(env["keypath"]))
258+
259+
assert_status_code(response, 401)
260+
261+
invalid_jwt_response = requests.get(env["api_key_endpoint"], headers={"Authorization": "Bearer invalid"})
262+
263+
assert_status_code(invalid_jwt_response, 401)
264+
265+
success()
266+
267+
268+
test_functions = [
269+
no_header,
270+
no_jwt_header,
271+
invalid_jwt_header,
272+
incorrect_jwt_invalid_issuer,
273+
incorrect_jwt_invalid_audience,
274+
incorrect_jwt_invalid_scope,
275+
incorrect_jwt_expired,
276+
no_scope,
277+
different_secret,
278+
# verify the behavior of a "normal" api key endpoint before running the full endpoint excercise
279+
verify_api_key_endpoint_invalid,
280+
verify_api_key_endpoint_empty,
281+
endpoint_happy_path,
282+
endpoint_subsequent_calls,
283+
other_endpoint_does_not_accept_jwts,
284+
]
285+
286+
287+
def run_testbed():
288+
289+
print("VERIFYING ENV FILE")
290+
print("------------------")
291+
292+
json_env = read_env()
293+
if "server" not in json_env:
294+
print("ERROR: server not configured in env file")
295+
sys.exit(1)
296+
if "keypath" not in json_env:
297+
print("ERROR keypath not configured in env file")
298+
sys.exit(1)
299+
300+
json_env["bootstrap_endpoint"] = json_env["server"] + BOOTSTRAP_ENDPOINT
301+
json_env["api_key_endpoint"] = json_env["server"] + API_KEY_ENDPOINT
302+
303+
print("RUNNING TESTBED")
304+
print("---------------")
305+
306+
for i in range(len(test_functions)):
307+
test_functions[i](i, json_env)
308+
309+
print()
310+
print("Done.")
311+
312+
313+
if __name__ == "__main__":
314+
run_testbed()

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ ipython
1010
jupyter_client
1111
mypy; python_version >= '3.6'
1212
nbconvert
13+
pyjwt>=2.4.0; python_version >= '3.6'
14+
pyjwt; python_version < '3.6'
1315
pytest
1416
pytest-cov
1517
pytest-mypy; python_version >= '3.5'

0 commit comments

Comments
 (0)