Repository navigation
fix: restrict v1 job logs to operators - #166
matthewberry wants to merge 2 commits into
Conversation
Job logs carry whatever the container printed, which can include internal paths and configuration, and until now anyone holding a job id could list them with ?include=logs or download them from /artifacts/<name>. Logs are now for operators only, meaning logged-in members of the reporting group (require_reporting_group): - ?include=logs from anyone else is refused with 403; - downloading a top-level log or marker name answers 404 to anyone else, the same as a missing file. The path is normalized first, so ./log cannot reach what log is refused. - With no reporting group configured, nobody counts as an operator. The MCP list_job_artifacts tool loses its include_logs option: the MCP server calls the API without the operator's cookie, so the option could never succeed.
matthewberry
left a comment
There was a problem hiding this comment.
Looks right. One finding, inline on _is_operator, that matters once reportingGroup is set. Nothing blocking today.
Every way to read a log through the API is covered. I enumerated the paths that serve files from a job's out/:
- The two v1 routes: both are gated. The download check normalizes the path first;
..and a leading/were already rejected, and since object names are case-sensitive,LOGis a different object rather than a way around the check. A non-operator gets the same 404 as for a missing file. - Legacy
files.pyroutes:inputs/gives presigned URLs for/in/only,errors/reads{job_id}/errors.txtoutsideout/, andresults/returns per-tool results. The rest are uploads, the chemscraper export, and genome-viewer data. - Presigned URLs: the only listing is
get_file_urls, called once, for/in/. - MCP: it only lists artifacts, and now never with logs.
- Direct MinIO access: per #164's allowlist, anonymous access covers
*/in/*and the ez-specificity complexes only, notout/log.
The deploy note is accurate: nothing in chart/ or app/cfg sets reportingGroup or REPORTING_GROUP.
Frontend callers, checked on main: the PR body notes the frontend search covered local checkouts only. I fetched all 15 frontend repos and searched each default branch for /artifacts, include=logs, include_logs and includeLogs. Nothing matched, so nothing in a frontend loses access.
Minor
- Listing with
?include=logsreturns 403, while downloading returns 404. That's deliberate and fine; the 403 doesn't reveal whether any log exists. - This is stacked on #163, so retarget it to
mainafter #163 merges, unless GitHub does it when #163's branch is deleted.
Not run locally; CI pytest and docker were still pending when I reviewed.
| require_reporting_group refuses rather than treating as "anyone logged in". | ||
| """ | ||
| try: | ||
| await require_reporting_group(request) |
There was a problem hiding this comment.
Once reportingGroup is set, this lets anyone make the backend stall on an untimed outbound call. I found this by reading; I haven't run it.
- This awaits
require_reporting_group, which callsvalidate_auth_cookie→userinfo(). That's a synchronousrequests.get(app_config['auth']['userInfoUrl'], ...)with notimeout, so inside this async route it blocks the event loop, and with it every other request, until the call returns. userinfo()runs even when there's no cookie (token=None), so an anonymous request still makes the call.- So anyone can trigger it with
GET /v1/jobs/{any id}/artifacts/logor?include=logs. /internal/reports/usagealready has the same problem. This adds two more public routes that trigger it.- It's dormant today, because the unset-group check in
require_reporting_groupraises before any network call.
Cheapest fix: return False without the lookup when there's no auth cookie, and give the requests.get in userinfo() a timeout. Running the lookup in a threadpool (run_in_threadpool) would also stop it blocking the event loop.
There was a problem hiding this comment.
Confirmed by reading userinfo_service.py: the requests.get has no timeout, and the lookup ran even with no cookie. I did not reproduce the stall itself. Fixed in 7eca1eb:
validate_auth_cookiereturnsNonewithout a lookup when there is no cookie, so anonymous hits on a log URL make no outbound call. That lookup would have failed and returnedNoneanyway, so the reports and molli callers see no change.userinfo()now has a 5-second timeout; a timeout counts as not logged in.require_reporting_groupruns the lookup withrun_in_threadpool, which also fixes/internal/reports/usage.
Three new tests: an anonymous log request makes no requests.get, the lookup passes a timeout, and the lookup does not run on the event loop. All three failed before the change. Full suite: 636 passed.
Separately, userinfo() logs the access token at debug level on failure (line 61). I left that alone here; it is worth its own change.
There was a problem hiding this comment.
Based on past experience, we may need to be careful with this... One reason that I never enabled the userinfo checks in the backend (even tho some of the code is already there) is that I was afraid we may accidentally create a situation where we DDoS ourselves 😅
As I understand it, the change proposed in this PR is only for the reporting / logs endpoints, which are not typically used in the frontends. If that is true, then all is well here 👍
Downside: But I would advise caution against using this pattern on the more frequently used job submission/status endpoints that the frontend applications use. Supporting such a case would require much testing and thinking thru the full chain of requests. Every time we decode the cookie we follow the chain: mmli-backend -> OAuth2 Proxy -> Redis. Ultimately Redis is pretty good at caching (probably better than etcd was with my last not-so-great experience), so maybe this case would have less performance concerns than I'm thinking it might.
Upside: if a timeout simply means "not logged in", then an end user's path thru using the app should not be affected much (or at all) by a failure in the login system if it were to get overwhelmed. Some frontend users may see issues logging in, but right now that is an edge case anyways since so few people sign up and log in (as there's no real benefit or incentive for them to do so)
|
@bodom0015 , I'd be happy to replace the authorized group here if you'd like to suggest an alernative. |
Review on the operator-only logs change: the check reaches userinfo(), a synchronous requests.get with no timeout, from an async dependency, and it ran even for requests with no cookie. Once a reporting group is set, anyone could make the backend wait on that call from a public log URL, with the event loop blocked meanwhile. /internal/reports/usage already had the same problem. - validate_auth_cookie returns None without a lookup when there is no cookie. The lookup would have failed and returned None anyway, so callers (reports, molli) see no change. - userinfo() times out after 5 seconds; a timeout is treated as not logged in, like any other lookup failure. - require_reporting_group runs the lookup in a thread pool, so a slow one no longer blocks other requests.
bodom0015
left a comment
There was a problem hiding this comment.
LGTM 👍 minor concerns about possible future usage of this pattern, but nothing that should prevent merging the current PR 🙏 we may want to deploy this one and test it
Especially if we're assuming that the unused userinfo code that I had previously written would work out of the box 😅
I may not be understanding the question - are you asking if it's ok that we reuse the |
Stacked on #163, which hides logs from the default listing. This PR makes them inaccessible to anyone but operators. The review on #163 deferred this.
Change
"Operator" means a logged-in member of the reporting group, checked with the existing
require_reporting_group.GET /v1/jobs/{id}/artifacts?include=logs: 403 problem for anyone else.GET /v1/jobs/{id}/artifacts/{name}for a top-level log or marker name (LOG_ARTIFACTS): 404 for anyone else, the same answer as a missing file, so it does not confirm the log exists../logcannot reach whatlogis refused.log(e.g.run1/log) is unaffected, matching fix: hide the job template's log and marker files from the v1 artifact listing #163's top-level-only rule.require_reporting_group, which refuses rather than falling open.list_job_artifactslosesinclude_logs. The MCP server calls the API without the operator's cookie, so the option could never succeed.validate_auth_cookieskips the userinfo lookup when there is no cookie;userinfo()times out after 5 seconds;require_reporting_groupruns the lookup in a thread pool, which also fixes/internal/reports/usage.Deploy note
After deploy, nobody can read logs through the API. Operators can still read them in MinIO directly. Three things stand in the way, and all three are already true for
/internal/reports/usage:auth.reportingGroupis not set in any values file.auth.userInfoUrlis the chart defaulthttp://oauth2.proxy.local/oauth2/userinfo, which does not resolve from the prod backend pod, so every lookup fails..platform.moleculemaker.org, while the API is served atmmli.fastapi.mmli2.ncsa.illinois.edu, so browsers don't send the cookie to the API.Making operator access work means serving the API under
*.platform.moleculemaker.org(or changing the cookie domain), pointinguserInfoUrlat the in-cluster oauth2-proxy, making sure Keycloak puts groups in the token, and then setting the group. That is out of scope here.Callers checked
/artifactsandinclude=logsand found none. The reviewer then searched the default branch of all 15 frontend repos and found no callers either.Tests
New cases in
TestArtifacts:?include=logsgets 403;log,./loganderror.logas a non-operator gets 404;run1/logstays downloadable;Nine of these failed before the changes they cover. Full suite: 636 passed.