Skip to content

Feat/dpop - #314

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

Avantol13 wants to merge 15 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 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.

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.

I'd vote to defer for now. a route table would only centralize the prefix matching - each service's endpoint also flows through its own --*-endpoint CLI option, resolve_service_endpoints (default + same-host check), the proxy config, and the generated nextflow config. so a third service needs customization regardless; worth revisiting if one actually shows up (I think it will be more clear at that point what can be consolidated)

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.

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.

agreed it's not an issue today. in-process mode is single threaded, and the threaded HTTP mode only serves download_object_manifest, which reads. if a test ever starts writing concurrently over HTTP, we'd maybe need to revisit but I don't expect we'd need / want a fake indexd server for that (we'd probably just patch at the requests / httpx2 call instead of handling this way). All of this was to remove the weird dependency on indexd for unit tests

Comment thread tests/dpop/test_dpop.py Outdated
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?

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.

great catch, that was a legit missed edge case. fixing

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 ?

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.

good point, adding a note to the nextflow howto

Comment thread tests/dpop/test_dpop.py Outdated
Comment thread tests/dpop/test_dpop.py Outdated

_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?

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, I was avoiding pulling in private globals/functions by hard-coding it - but I agree with you that it's more readable that way (and since this is just tests, I'm okay bending that "no importing privates" rule). I made this change and the suggested test breakup with testing a private function below too

Comment thread tests/dpop/test_dpop.py Outdated

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

@Avantol13 Avantol13 left a comment

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.

thank you for the comprehensive review!! I know this was a lot of new code and changes

Comment thread tests/dpop/test_dpop_nextflow.py Outdated

def capture(*args: Any, **kwargs: Any) -> _CompletedProcess:
argv = args[0]
captured["path"] = Path(argv[argv.index("-c") + 1])

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.

good catch, applying your suggestion

Comment thread tests/dpop/test_dpop.py
Comment on lines +201 to +217
def test_challenge_carrying_only_a_nonce_header_is_retried(
self, ec_key, requests_mock
):
"""A refusal with a nonce but no explanation is still read as a nonce demand."""
requests_mock.post(
TOKEN_ENDPOINT,
[
{
"status_code": 401,
"text": "",
"headers": {"DPoP-Nonce": "as-nonce-2"},
},
{"status_code": 200, "json": {"access_token": TASK_TOKEN}},
],
)

assert _exchange(ec_key) == (TASK_TOKEN, "as-nonce-2")

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.

deliberate. per RFC 9449 the server signals with error: use_dpop_nonce, but servers can attach a DPoP-Nonce header to every response on a DPoP endpoint, so the header alone doesn't mean "retry with this nonce". the rule in _is_dpop_nonce_error is: if the body explains the failure, trust the body. only when the body is empty is the header the only signal left, so we treat it as a nonce demand. the unrelated error test has a body saying something else, so retrying would just burn the budget and bury the real error. I'll update the docstrings so this is more clear

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

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.

I'd vote to defer for now. a route table would only centralize the prefix matching - each service's endpoint also flows through its own --*-endpoint CLI option, resolve_service_endpoints (default + same-host check), the proxy config, and the generated nextflow config. so a third service needs customization regardless; worth revisiting if one actually shows up (I think it will be more clear at that point what can be consolidated)

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 Author

Choose a reason for hiding this comment

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

good point, adding a note to the nextflow howto

Comment thread tests/dpop/test_dpop.py Outdated

_refusal(ec_key)

assert requests_mock.call_count == 3

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, I was avoiding pulling in private globals/functions by hard-coding it - but I agree with you that it's more readable that way (and since this is just tests, I'm okay bending that "no importing privates" rule). I made this change and the suggested test breakup with testing a private function below too

Comment thread tests/dpop/test_dpop.py Outdated
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 Author

Choose a reason for hiding this comment

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

great catch, that was a legit missed edge case. fixing

Comment thread tests/dpop/test_dpop_nextflow.py Outdated
run_cli(["main.nf", "-c", "mine.config"])

argv = nextflow_process.call_args.args[0]
assert argv[:5] == ["nextflow", "run", "main.nf", "-c", "mine.config"]

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.

no good reason, will switch it to _pipeline_args for consistency

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 Author

Choose a reason for hiding this comment

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

agreed it's not an issue today. in-process mode is single threaded, and the threaded HTTP mode only serves download_object_manifest, which reads. if a test ever starts writing concurrently over HTTP, we'd maybe need to revisit but I don't expect we'd need / want a fake indexd server for that (we'd probably just patch at the requests / httpx2 call instead of handling this way). All of this was to remove the weird dependency on indexd for unit tests

Comment thread tests/dpop/test_dpop.py Outdated
headers={"DPoP-Nonce": "as-nonce-1"},
)

_refusal(ec_key)

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'm going to remove it and be explicit

Comment thread tests/dpop/test_dpop.py
Comment on lines +275 to +300
@pytest.mark.parametrize(
"body,expected",
[
# Whichever field fence puts its explanation in, it is quoted back.
pytest.param({"json": {"message": "no can do"}}, "no can do", id="message"),
pytest.param(
{"json": {"error_description": "no can do"}},
"no can do",
id="error_description",
),
pytest.param({"json": {"detail": "no can do"}}, "no can do", id="detail"),
pytest.param({"json": {"error": "no can do"}}, "no can do", id="error"),
# An HTML or plain-text body is included rather than dropped.
pytest.param(
{"text": "<html>bad gateway</html>"}, "bad gateway", id="html"
),
# A JSON error with no field this SDK knows about is reported as-is.
pytest.param({"json": {"weird": ["shape"]}}, '"weird"', id="unknown_shape"),
# JSON that is not an object at all still has to survive the trip.
pytest.param({"json": ["no can do"]}, "no can do", id="json_array"),
],
)
def test_server_explanation_is_surfaced(
self, ec_key, requests_mock, body, expected
):
"""Whatever the server said reaches the caller."""

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.

good callout, I split it up a bit more, lmk what you think

@github-actions

Copy link
Copy Markdown

Integration Tests

filepath passed failed skipped SUBTOTAL
tests/test_graph_submit_and_query.py 12 1 1 14
tests/test_presigned_url.py 8 0 0 8
tests/test_dbgap.py 4 0 1 5
tests/test_centralized_auth.py 5 0 0 5
tests/test_audit_service.py 1 0 0 1
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 32 1 4 37

Please find the detailed integration test report here

Please find the Github Action logs here

@github-actions

Copy link
Copy Markdown

Failure Analysis

Please find the detailed test analysis report here

@github-actions

github-actions Bot commented Oct 1, 2026

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
TOTAL 12 1 1 14

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants