Skip to content

Commit e1d3400

Browse files
Authenticate CI via GitHub OIDC and surface clearer errors
Migrate CI credential handling to GitHub OIDC + AWS Secrets Manager, mirroring dropbox-sdk-dotnet: credential-consuming jobs assume an AWS IAM role and fetch the api-sdk-integration-test-creds and codecov-token secrets, replacing long-lived GitHub secrets. Credential-gated jobs are skipped on fork PRs, which cannot assume the role. Also decode the global access error on HTTP 403 responses and stop injecting the unused legacy user token, and authenticate the team integration fixture with a refresh token instead of a static token.
1 parent eee36cb commit e1d3400

5 files changed

Lines changed: 160 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,13 @@ jobs:
9393
sphinx-build -b html docs build/html
9494
9595
Integration:
96+
# Skip on fork PRs: they cannot assume the OIDC role.
97+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
9698
continue-on-error: true
9799
runs-on: ${{ matrix.os }}
100+
permissions: # required for OIDC
101+
id-token: write
102+
contents: read
98103
strategy:
99104
fail-fast: false
100105
max-parallel: 1
@@ -126,19 +131,27 @@ jobs:
126131
run: |
127132
python -m pip install "$(python -c "import glob,sys; w=glob.glob('dist/*.whl'); assert w, f'No wheel built. dist contains: {glob.glob(\"dist/*\")}'; print(w[0])")"
128133
134+
- name: Configure AWS credentials (OIDC)
135+
uses: aws-actions/configure-aws-credentials@v6
136+
with:
137+
role-to-assume: arn:aws:iam::082972943155:role/oidc-github-dropbox-dropbox-sdk-python-repo
138+
aws-region: us-west-2
139+
140+
- name: Get integration credentials from AWS Secrets Manager
141+
uses: aws-actions/aws-secretsmanager-get-secrets@v3
142+
with:
143+
secret-ids: |
144+
CREDS,api-sdk-integration-test-creds
145+
parse-json-secrets: true
146+
129147
- name: Run integration tests
130148
env:
131-
LEGACY_USER_DROPBOX_TOKEN: ${{ secrets.LEGACY_USER_DROPBOX_TOKEN }}
132-
LEGACY_USER_CLIENT_ID: ${{ secrets.LEGACY_USER_CLIENT_ID }}
133-
LEGACY_USER_CLIENT_SECRET: ${{ secrets.LEGACY_USER_CLIENT_SECRET }}
134-
LEGACY_USER_REFRESH_TOKEN: ${{ secrets.LEGACY_USER_REFRESH_TOKEN }}
135-
SCOPED_USER_CLIENT_ID: ${{ secrets.SCOPED_USER_CLIENT_ID }}
136-
SCOPED_USER_CLIENT_SECRET: ${{ secrets.SCOPED_USER_CLIENT_SECRET }}
137-
SCOPED_USER_REFRESH_TOKEN: ${{ secrets.SCOPED_USER_REFRESH_TOKEN }}
138-
SCOPED_TEAM_DROPBOX_TOKEN: ${{ secrets.SCOPED_TEAM_DROPBOX_TOKEN }}
139-
SCOPED_TEAM_CLIENT_ID: ${{ secrets.SCOPED_TEAM_CLIENT_ID }}
140-
SCOPED_TEAM_CLIENT_SECRET: ${{ secrets.SCOPED_TEAM_CLIENT_SECRET }}
141-
SCOPED_TEAM_REFRESH_TOKEN: ${{ secrets.SCOPED_TEAM_REFRESH_TOKEN }}
142-
DROPBOX_SHARED_LINK: ${{ secrets.DROPBOX_SHARED_LINK }}
149+
SCOPED_USER_CLIENT_ID: ${{ env.CREDS_SCOPED_USER_CLIENT_ID }}
150+
SCOPED_USER_CLIENT_SECRET: ${{ env.CREDS_SCOPED_USER_CLIENT_SECRET }}
151+
SCOPED_USER_REFRESH_TOKEN: ${{ env.CREDS_SCOPED_USER_REFRESH_TOKEN }}
152+
SCOPED_TEAM_CLIENT_ID: ${{ env.CREDS_SCOPED_TEAM_CLIENT_ID }}
153+
SCOPED_TEAM_CLIENT_SECRET: ${{ env.CREDS_SCOPED_TEAM_CLIENT_SECRET }}
154+
SCOPED_TEAM_REFRESH_TOKEN: ${{ env.CREDS_SCOPED_TEAM_REFRESH_TOKEN }}
155+
DROPBOX_SHARED_LINK: ${{ env.CREDS_DROPBOX_SHARED_LINK }}
143156
run: |
144157
pytest -v test/integration/test_dropbox.py

.github/workflows/coverage.yml

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ on:
1010
jobs:
1111
Coverage:
1212
runs-on: ubuntu-latest
13+
permissions: # required for OIDC
14+
id-token: write
15+
contents: read
1316
steps:
1417
- uses: actions/checkout@v7
1518
- name: Setup Python environment
@@ -32,14 +35,34 @@ jobs:
3235
run: |
3336
coverage run --rcfile=.coveragerc -m pytest test/unit/
3437
coverage xml
38+
# Upload steps gated to same-repo runs: fork PRs cannot assume the role.
39+
- name: Configure AWS credentials (OIDC)
40+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
41+
uses: aws-actions/configure-aws-credentials@v6
42+
with:
43+
role-to-assume: arn:aws:iam::082972943155:role/oidc-github-dropbox-dropbox-sdk-python-repo
44+
aws-region: us-west-2
45+
- name: Get Codecov token from AWS Secrets Manager
46+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
47+
uses: aws-actions/aws-secretsmanager-get-secrets@v3
48+
with:
49+
secret-ids: |
50+
CODECOV_TOKEN,codecov-token-dropbox-sdk-python
51+
parse-json-secrets: false
3552
- name: Publish Coverage
3653
uses: codecov/codecov-action@v7
54+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
3755
with:
38-
token: ${{ secrets.CODECOV_TOKEN }}
56+
token: ${{ env.CODECOV_TOKEN }}
3957
flags: unit
4058
fail_ci_if_error: true
4159
IntegrationCoverage:
60+
# Skip on fork PRs: they cannot assume the OIDC role.
61+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
4262
runs-on: ubuntu-latest
63+
permissions: # required for OIDC
64+
id-token: write
65+
contents: read
4366
steps:
4467
- uses: actions/checkout@v7
4568
- name: Setup Python environment
@@ -58,26 +81,33 @@ jobs:
5881
pip install -r requirements.txt
5982
pip install -r test/requirements.txt
6083
pip install .
61-
- name: Generate Unit Test Coverage
84+
- name: Configure AWS credentials (OIDC)
85+
uses: aws-actions/configure-aws-credentials@v6
86+
with:
87+
role-to-assume: arn:aws:iam::082972943155:role/oidc-github-dropbox-dropbox-sdk-python-repo
88+
aws-region: us-west-2
89+
- name: Get integration credentials from AWS Secrets Manager
90+
uses: aws-actions/aws-secretsmanager-get-secrets@v3
91+
with:
92+
secret-ids: |
93+
CREDS,api-sdk-integration-test-creds
94+
CODECOV_TOKEN,codecov-token-dropbox-sdk-python
95+
parse-json-secrets: true
96+
- name: Generate Coverage
6297
env:
63-
LEGACY_USER_DROPBOX_TOKEN: ${{ secrets.LEGACY_USER_DROPBOX_TOKEN }}
64-
LEGACY_USER_CLIENT_ID: ${{ secrets.LEGACY_USER_CLIENT_ID }}
65-
LEGACY_USER_CLIENT_SECRET: ${{ secrets.LEGACY_USER_CLIENT_SECRET }}
66-
LEGACY_USER_REFRESH_TOKEN: ${{ secrets.LEGACY_USER_REFRESH_TOKEN }}
67-
SCOPED_USER_CLIENT_ID: ${{ secrets.SCOPED_USER_CLIENT_ID }}
68-
SCOPED_USER_CLIENT_SECRET: ${{ secrets.SCOPED_USER_CLIENT_SECRET }}
69-
SCOPED_USER_REFRESH_TOKEN: ${{ secrets.SCOPED_USER_REFRESH_TOKEN }}
70-
SCOPED_TEAM_DROPBOX_TOKEN: ${{ secrets.SCOPED_TEAM_DROPBOX_TOKEN }}
71-
SCOPED_TEAM_CLIENT_ID: ${{ secrets.SCOPED_TEAM_CLIENT_ID }}
72-
SCOPED_TEAM_CLIENT_SECRET: ${{ secrets.SCOPED_TEAM_CLIENT_SECRET }}
73-
SCOPED_TEAM_REFRESH_TOKEN: ${{ secrets.SCOPED_TEAM_REFRESH_TOKEN }}
74-
DROPBOX_SHARED_LINK: ${{ secrets.DROPBOX_SHARED_LINK }}
98+
SCOPED_USER_CLIENT_ID: ${{ env.CREDS_SCOPED_USER_CLIENT_ID }}
99+
SCOPED_USER_CLIENT_SECRET: ${{ env.CREDS_SCOPED_USER_CLIENT_SECRET }}
100+
SCOPED_USER_REFRESH_TOKEN: ${{ env.CREDS_SCOPED_USER_REFRESH_TOKEN }}
101+
SCOPED_TEAM_CLIENT_ID: ${{ env.CREDS_SCOPED_TEAM_CLIENT_ID }}
102+
SCOPED_TEAM_CLIENT_SECRET: ${{ env.CREDS_SCOPED_TEAM_CLIENT_SECRET }}
103+
SCOPED_TEAM_REFRESH_TOKEN: ${{ env.CREDS_SCOPED_TEAM_REFRESH_TOKEN }}
104+
DROPBOX_SHARED_LINK: ${{ env.CREDS_DROPBOX_SHARED_LINK }}
75105
run: |
76106
coverage run --rcfile=.coveragerc -m pytest test/integration/test_dropbox.py
77107
coverage xml
78108
- name: Publish Coverage
79109
uses: codecov/codecov-action@v7
80110
with:
81-
token: ${{ secrets.CODECOV_TOKEN }}
111+
token: ${{ env.CODECOV_TOKEN }}
82112
flags: integration
83113
fail_ci_if_error: true

dropbox/dropbox_client.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from datetime import datetime, timedelta
2222
from dropbox.auth import (
23+
AccessError_validator,
2324
AuthError_validator,
2425
RateLimitError_validator,
2526
)
@@ -51,7 +52,7 @@
5152
pinned_session,
5253
DEFAULT_TIMEOUT
5354
)
54-
from stone.backends.python_rsrc import stone_serializers
55+
from stone.backends.python_rsrc import stone_serializers, stone_validators
5556

5657
PATH_ROOT_HEADER = 'Dropbox-API-Path-Root'
5758
HTTP_STATUS_INVALID_PATH_ROOT = 422
@@ -698,7 +699,28 @@ def raise_dropbox_error_for_resp(self, res):
698699
else:
699700
retry_after = None
700701
raise RateLimitError(request_id, err, retry_after)
701-
elif res.status_code in (403, 404, 409):
702+
elif res.status_code == 403:
703+
# Access errors are global API errors rather than errors from the
704+
# individual route. Decode strictly so a route-specific error that
705+
# happens to use HTTP 403 can still be handled by the requester.
706+
try:
707+
body = res.json()
708+
err = stone_serializers.json_compat_obj_decode(
709+
AccessError_validator, body['error'], strict=True)
710+
except (ValueError, TypeError, KeyError,
711+
stone_validators.ValidationError):
712+
return
713+
714+
user_message = body.get('user_message')
715+
if isinstance(user_message, dict):
716+
user_message_text = user_message.get('text')
717+
user_message_locale = user_message.get('locale')
718+
else:
719+
user_message_text = None
720+
user_message_locale = None
721+
raise ApiError(request_id, err,
722+
user_message_text, user_message_locale)
723+
elif res.status_code in (404, 409):
702724
# special case handled by requester
703725
return
704726
elif not (200 <= res.status_code <= 299):

test/integration/test_dropbox.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,15 @@ def bad_secret_dbx_from_env(dbx_session):
114114

115115
@pytest.fixture()
116116
def dbx_team_from_env(dbx_session):
117-
team_oauth2_token = _value_from_env_or_die(
118-
format_env_name(SCOPED_KEY, TEAM_KEY, ACCESS_TOKEN_KEY))
119-
return DropboxTeam(team_oauth2_token, session=dbx_session)
117+
refresh_token = _value_from_env_or_die(
118+
format_env_name(SCOPED_KEY, TEAM_KEY, REFRESH_TOKEN_KEY))
119+
app_key = _value_from_env_or_die(
120+
format_env_name(SCOPED_KEY, TEAM_KEY, CLIENT_ID_KEY))
121+
app_secret = _value_from_env_or_die(
122+
format_env_name(SCOPED_KEY, TEAM_KEY, CLIENT_SECRET_KEY))
123+
return DropboxTeam(oauth2_refresh_token=refresh_token,
124+
app_key=app_key, app_secret=app_secret,
125+
session=dbx_session)
120126

121127

122128
@pytest.fixture()

test/unit/test_dropbox_unit.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
)
1616
from dropbox.content_hash import content_hash
1717
from dropbox.common import PathRoot
18-
from dropbox.exceptions import AuthError, BadInputError
18+
from dropbox.exceptions import ApiError, AuthError, BadInputError
1919
from dropbox.oauth import OAuth2FlowNoRedirectResult, DropboxOAuth2FlowNoRedirect
2020
from datetime import datetime, timedelta
2121

@@ -509,6 +509,62 @@ def test_refresh_raises_after_exhausting_retries(self, server_error_session_inst
509509
# Initial attempt + 2 retries.
510510
assert server_error_session_instance.post.call_count == 3
511511

512+
def test_upload_decodes_global_access_error(self, mocker):
513+
payload = {
514+
'error': {
515+
'.tag': 'invalid_account_type',
516+
'invalid_account_type': {'.tag': 'feature'},
517+
},
518+
'error_summary': 'invalid_account_type/feature/',
519+
'user_message': {
520+
'text': 'Uploads are unavailable for this account.',
521+
'locale': 'en',
522+
},
523+
}
524+
response = mock.MagicMock(status_code=403)
525+
response.headers = {
526+
'content-type': 'application/json',
527+
'x-dropbox-request-id': 'request-id',
528+
}
529+
response.json.return_value = payload
530+
response.content = json.dumps(payload).encode('utf-8')
531+
response.text = json.dumps(payload)
532+
session_obj = create_session()
533+
mocker.patch.object(session_obj, 'post', return_value=response)
534+
dbx = Dropbox(oauth2_access_token=ACCESS_TOKEN, session=session_obj)
535+
536+
with pytest.raises(ApiError) as exc_info:
537+
dbx.files_upload(b'test', '/test.txt')
538+
539+
assert exc_info.value.request_id == 'request-id'
540+
assert exc_info.value.error.is_invalid_account_type()
541+
assert exc_info.value.error.get_invalid_account_type().is_feature()
542+
assert exc_info.value.user_message_text == \
543+
'Uploads are unavailable for this account.'
544+
assert exc_info.value.user_message_locale == 'en'
545+
546+
def test_upload_preserves_route_specific_403_error(self, mocker):
547+
payload = {
548+
'error': {'.tag': 'payload_too_large'},
549+
'error_summary': 'payload_too_large/',
550+
}
551+
response = mock.MagicMock(status_code=403)
552+
response.headers = {
553+
'content-type': 'application/json',
554+
'x-dropbox-request-id': 'request-id',
555+
}
556+
response.json.return_value = payload
557+
response.content = json.dumps(payload).encode('utf-8')
558+
response.text = json.dumps(payload)
559+
session_obj = create_session()
560+
mocker.patch.object(session_obj, 'post', return_value=response)
561+
dbx = Dropbox(oauth2_access_token=ACCESS_TOKEN, session=session_obj)
562+
563+
with pytest.raises(ApiError) as exc_info:
564+
dbx.files_upload(b'test', '/test.txt')
565+
566+
assert exc_info.value.error.is_payload_too_large()
567+
512568

513569
class TestAutoContentHash:
514570

0 commit comments

Comments
 (0)