Skip to content

Feat/dpop - #314

Open
Avantol13 wants to merge 9 commits into
masterfrom
feat/dpop
Open

Avantol13 wants to merge 9 commits into
masterfrom
feat/dpop

Conversation

@Avantol13

@Avantol13 Avantol13 commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Depends on:

New Features

  • DPoP support (CLI & SDK)
  • Nextflow run support utilizing DPoP (CLI & SDK)

Breaking Changes

  • 3.13 only due to required updates from upstream dependencies, including authutils and other Gen3 libraries (this matches the Python version used and required by other Gen3 services)

Bug Fixes

Improvements

  • Unit tests parallelized, down to ~7s from ~40s locally (w/ the new tests included) - looks like existing runs would take ~30s before these new tests and on this branch they take ~17s, so still almost half the time with many new tests added
  • Rip out coupling to indexd by actually mocking in unit tests

Dependency updates

Deployment changes

@github-actions

Copy link
Copy Markdown

The style in this PR agrees with black. ✔️

This formatting comment was generated automatically by a script in uc-cdis/wool.

@github-actions

Copy link
Copy Markdown

Integration Tests

Test summary after running integration tests

filepath passed failed skipped SUBTOTAL
tests/test_dbgap.py 4 0 1 5
tests/test_ras_passport.py 0 0 2 2
tests/test_data_upload.py 8 0 1 9
tests/test_graph_submit_and_query.py 12 1 1 14
tests/test_presigned_url.py 7 0 0 7
tests/test_centralized_auth.py 5 0 0 5
tests/test_google_data_access.py 1 0 0 1
tests/test_audit_service.py 1 0 0 1
tests/test_drs_endpoint.py 2 0 0 2
tests/test_gen3_sdk.py 1 0 0 1
TOTAL 41 1 5 47

Test summary after rerunning failed integration tests

filepath passed SUBTOTAL
tests/test_graph_submit_and_query.py 1 1
TOTAL 1 1

Please find the detailed integration test report here

Please find the detailed integration test report after rerunning failed tests here

Please find the Github Action logs here

Comment thread docs/howto/nextflow.md Outdated
Comment thread gen3/dpop.py Outdated
"""
is_s3 = service == _SERVICE_S3
not_forwarded = (
_HEADERS_NOT_FORWARDED_TO_S3 if is_s3 else _HEADERS_NOT_FORWARDED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit redundant since _HEADERS_NOT_FORWARDED_TO_S3 is just _HEADERS_NOT_FORWARDED except "authorization", and the "authorization" header is overwritten anyway a couple lines later if "not is_s3".

Could we just remove "authorization" from _HEADERS_NOT_FORWARDED and get rid of _HEADERS_NOT_FORWARDED_TO_S3?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, good call. I cleaned it up and added a check b/c of case-sensitivity causing duplicates

Comment thread gen3/dpop.py
else:
logging.warning(
f"Refusing to proxy {path}: only /ga4gh/tes and /s3 paths are "
"proxied. Check the endpoints your pipeline is configured with."

@paulineribeyre paulineribeyre Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paths start with either /ga4gh/tes/v1 or /workflows, see config here.

Plus, the S3 endpoint is also exposed at the app root (see here).

So a request to Gen3 S3 could start with /workflows or /workflows/s3 or /ga4gh/tes/v1 or /ga4gh/tes/v1/s3 (although the last 2 are not documented or used). But never just /s3.

I'm not sure what a reliable way to identify S3 requests while maintaining root S3 endpoint support would be. We could list all the non-S3 routes but that's not very future-proof.

For now to unblock my testing, i made this change, which assumes S3 requests are non-root.

Edit: I see the proxy is accepting /s3 requests and forwarding them to /workflows/s3, and DPOP_PROTECTED_PATHS in gen3-workflow matches that, so maybe I misunderstood the intent. We can discuss it when you're back!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the proxy itself has its own endpoints and then translates those to the real Gen3 Workflow, and I'd rather keep them separate and have the mapping in the proxy itself. We can control in the Nextflow config what local proxy endpoint to hit for S3 and TES respectively (and the auto-generated config should already be doing that). e.g. this should've worked out of the box

in your edit: /ga4gh/tes and /s3 are the proxy's own local namespace, not commons paths. the commons paths only appear in TES_ENDPOINT / S3_ENDPOINT, and the generated nextflow config points nextflow at the local ones.

round trip for S3: nextflow hits 127.0.0.1:port/s3/bucket/key, proxy strips /s3 and appends to {commons}/workflows/s3, signs htu over {commons}/workflows/s3/bucket/key.

gen3-workflow should rebuild that same string to check.

What was the config that sent /workflows/... at the proxy? e.g. why didn't what was written work out of the box?

if it was a hand-written pipeline config or the auto-generated config... we could fix that instead.

re: your changes, if we want to keep this and support commons paths matching locally - I think it needs some updates either way. It looks like this will happen:

/workflows/s3/bucket/key.txt  -> ('https://cx/workflows/s3/bucket/key.txt', 's3')
/workflows/bucket/key.txt     -> ('https://cx/ga4gh/tes/bucket/key.txt', 'tes')
/s3/bucket/key.txt            -> None

the middle one is the root-mounted S3 case you raised - after /workflows/ matches, anything that isn't /s3/ falls through to the TES base, so it goes to
{commons}/ga4gh/tes/<bucket>/<key> and since the service isn't s3 we replace the
SigV4 header with Authorization: DPoP <token>. and the last one is what the
generated config sends, so generated-config runs 404.

But... I still am not convinced we need to change this. The local proxy and nextflow config can be opinionated - we can choose to only support /s3 -> /workflows/s3 instead of all 4 options the service itself supports.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My integration tests do not use the path that updates the config automatically. I see that I misconfigured aws.client.endpoint (<proxy>/workflows/s3 instead of <proxy>/s3), that's probably where the issue came from. I'll fix that and test - no need to change anything if that works 👍

Maybe a bit of documentation/docstring about this endpoint mapping would be nice though, so the next reader isn't confused like I was?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok that was only part of the issue - all the existing TES tests were also pointing at /workflows/s3, which i didn't change when i updated them to use the dpop proxy.

Avantol13 and others added 2 commits September 14, 2026 10:32
Co-authored-by: Pauline Ribeyre <4224001+paulineribeyre@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Integration Tests

Test summary after running integration tests

filepath passed failed skipped SUBTOTAL
tests/test_graph_submit_and_query.py 12 1 1 14
tests/test_data_upload.py 8 0 1 9
tests/test_presigned_url.py 8 0 0 8
tests/test_centralized_auth.py 5 0 0 5
tests/test_audit_service.py 1 0 0 1
tests/test_dbgap.py 4 0 1 5
tests/test_google_data_access.py 1 0 0 1
tests/test_gen3_sdk.py 1 0 0 1
tests/test_ras_passport.py 0 0 2 2
TOTAL 40 1 5 46

Test summary after rerunning failed integration tests

filepath passed SUBTOTAL
tests/test_graph_submit_and_query.py 1 1
TOTAL 1 1

Please find the detailed integration test report here

Please find the detailed integration test report after rerunning failed tests here

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Integration Tests

Test summary after running integration tests

filepath passed failed skipped SUBTOTAL
tests/test_graph_submit_and_query.py 12 1 1 14
tests/test_data_upload.py 8 0 1 9
tests/test_presigned_url.py 8 0 0 8
tests/test_centralized_auth.py 5 0 0 5
tests/test_dbgap.py 4 0 1 5
tests/test_google_data_access.py 1 0 0 1
tests/test_audit_service.py 1 0 0 1
tests/test_gen3_sdk.py 1 0 0 1
tests/test_ras_passport.py 0 0 2 2
TOTAL 40 1 5 46

Test summary after rerunning failed integration tests

filepath passed SUBTOTAL
tests/test_graph_submit_and_query.py 1 1
TOTAL 1 1

Please find the detailed integration test report here

Please find the detailed integration test report after rerunning failed tests here

Please find the Github Action logs here

… match the known env vars for tokens so other processes/users cannot just hit it without also sending auth

@nss10 nss10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work. Left some comments, questions and suggestions.

Comment thread gen3/dpop.py
Comment on lines +176 to +181
if httpx2.URL(endpoint).host != commons_host:
raise Gen3AuthError(
f"{option} is {endpoint}, which is not on {commons_host} - the "
"commons that issued your credentials. The task token is only "
"valid there, so it will not be sent anywhere else."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The there and anywhere else in the error message seemed a little ambiguous while reading. Could we try something like this ⬇

Suggested change
if httpx2.URL(endpoint).host != commons_host:
raise Gen3AuthError(
f"{option} is {endpoint}, which is not on {commons_host} - the "
"commons that issued your credentials. The task token is only "
"valid there, so it will not be sent anywhere else."
)
if httpx2.URL(endpoint).host != commons_host:
raise Gen3AuthError(
f"{option} is {endpoint}, which is not on {commons_host} - the "
"commons that issued your credentials. For security, the task "
f"token is only sent to the host that issued it. Change {option} "
f"to a URL on {commons_host}."
)

Comment thread gen3/dpop.py
Comment on lines +760 to +761
# A client acting on behalf of a user appends the user ID to its token.
return candidate.split(";userId=")[0] or None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pauline -- Do we still need this here? Now that we are moving away from client accessing S3 bucket on users' behalf?

Comment thread gen3/dpop.py
"""
Route a path to its upstream base, or refuse to route it at all.

Only TES and S3 traffic belongs on this proxy, so the two prefixes are

@nss10 nss10 Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /ga4gh/tes and /s3 prefixes are hardcoded in _resolve_upstream_url and referenced in several comments. If a third service is added later, those will need to be found and updated together. Worth considering whether the route table should be data-driven (e.g. a dict of prefix → service) so adding a service is one change in one place — but fine to defer if the two-service assumption and extensibility is out of scope for now.

Comment thread pyproject.toml
gen3users = "*"
joserfc = ">=1.7.3"

authutils = {git = "https://github.com/uc-cdis/authutils.git", rev = "feat/dpop"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder to pin it to master, after authutils PR is merged

Comment thread tests/fake_indexd.py
Comment on lines +74 to +75
self.records: dict[str, dict] = {}
self.bundles: dict[str, dict] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should reads and writes to these dicts be thread safe? Since mutliple threads could "technically" write in parallel. I don't think it is an issue with the current use case though.

Comment thread tests/dpop/test_dpop.py
Comment on lines +365 to +370
def test_no_requested_lifetime_skips_the_check(self, ec_key, requests_mock):
"""Without an explicit lifetime the server picks one, so nothing is checked."""
token, _ = _exchange(ec_key, api_key=_api_key_expiring_in(-60))

assert token == TASK_TOKEN
assert requests_mock.called

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a little off to me — maybe I'm missing something. There is no restriction on fetching a TASK_TOKEN with an already-expired API key as long as no explicit task_token_expiration is provided?

At dpop.py#L920-921 we simply skip the check when task_token_expiration is None. Is this a missed edge case, or are we intentionally deferring to the server to reject the expired API key?

Comment thread gen3/dpop.py
method, upstream_url, scope.get("headers", []), service
)

with tempfile.SpooledTemporaryFile(max_size=_MAX_BODY_SIZE_IN_MEMORY) as body:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know buffering the body is necessary to support nonce retries, but I'm concerned about the disk space implications for large uploads. If a user uploads a 5 GB file, the proxy needs 5 GB of free space in /tmp for the duration of the transfer. With parallel uploads, that multiplies — a user could unknowingly need tens of gigabytes of temporary storage just to run the proxy.

This seems worth documenting. maybe in docs/howto/nextflow.md ?

Comment thread tests/dpop/test_dpop.py

with proxy.get("/s3/bucket/big", stream=True, timeout=120) as response:
content_length = response.headers["content-length"]
received = sum(len(chunk) for chunk in response.iter_content(65536))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably being nitpicky, but can we write 64*1024 instead of 65536

Suggested change
received = sum(len(chunk) for chunk in response.iter_content(65536))
received = sum(len(chunk) for chunk in response.iter_content(64 * 1024))

Comment thread tests/dpop/test_dpop.py

_refusal(ec_key)

assert requests_mock.call_count == 3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably 3 is implicitly understood, but can we have _MAX_NONCE_RETRIES + 1 to be cleaner?

Comment thread tests/dpop/test_dpop.py

assert response.status_code == 401
assert response.json() == {"error": "use_dpop_nonce"}
assert proxy.upstream.nonce_challenges_sent == 3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above --

Suggested change
assert proxy.upstream.nonce_challenges_sent == 3
assert proxy.upstream.nonce_challenges_sent == _MAX_NONCE_RETRIES + 1

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants