-
Notifications
You must be signed in to change notification settings - Fork 109
feat: extract project/environment helpers for multi-project support #657
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
DevonFulcher
wants to merge
4
commits into
df/migrate-requests-to-httpx
Choose a base branch
from
df/multiproject-phase1-core
base: df/migrate-requests-to-httpx
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+209
−122
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
35fa55d
feat: extract project/environment helpers for multi-project support
DevonFulcher 06dbf2a
feat: add server_b_toolsets exclusion mechanism for README generation
DevonFulcher a4eecc5
refactor: rename server_b_toolsets to multi_project_only_toolsets
DevonFulcher 70b2a82
refactor: remove unused multi_project_only_toolsets from phase 1
DevonFulcher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
3 changes: 3 additions & 0 deletions
3
.changes/unreleased/Enhancement or New Feature-20260317-021539.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| kind: Enhancement or New Feature | ||
| body: Extract project/environment helpers for multi-project support | ||
| time: 2026-03-17T02:15:39.921671-05:00 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import logging | ||
|
|
||
| import httpx | ||
|
|
||
| from dbt_mcp.oauth.dbt_platform import ( | ||
| DbtPlatformEnvironment, | ||
| DbtPlatformEnvironmentResponse, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def _get_all_environments_for_project( | ||
| *, | ||
| dbt_platform_url: str, | ||
| account_id: int, | ||
| project_id: int, | ||
| headers: dict[str, str], | ||
| page_size: int = 100, | ||
| ) -> list[DbtPlatformEnvironmentResponse]: | ||
| """Fetch all environments for a project using offset/page_size pagination.""" | ||
| offset = 0 | ||
| environments: list[DbtPlatformEnvironmentResponse] = [] | ||
| async with httpx.AsyncClient() as client: | ||
| while True: | ||
| response = await client.get( | ||
| f"{dbt_platform_url}/api/v3/accounts/{account_id}/projects/{project_id}/environments/?state=1&offset={offset}&limit={page_size}", | ||
| headers=headers, | ||
| ) | ||
| response.raise_for_status() | ||
| page = response.json()["data"] | ||
| environments.extend( | ||
| DbtPlatformEnvironmentResponse(**environment) for environment in page | ||
| ) | ||
| if len(page) < page_size: | ||
| break | ||
| offset += page_size | ||
| return environments | ||
|
|
||
|
|
||
| def resolve_environments( | ||
| environments: list[DbtPlatformEnvironmentResponse], | ||
| *, | ||
| prod_environment_id: int | None = None, | ||
| ) -> tuple[DbtPlatformEnvironment | None, DbtPlatformEnvironment | None]: | ||
| """Resolve prod and dev environments from a list of environment responses. | ||
| Returns a tuple of (prod_environment, dev_environment). | ||
| If prod_environment_id is provided, that specific environment is used as prod. | ||
| Otherwise, auto-detects based on deployment_type == "production". | ||
| Dev environment is always auto-detected based on deployment_type == "development". | ||
| """ | ||
| prod_environment: DbtPlatformEnvironment | None = None | ||
| dev_environment: DbtPlatformEnvironment | None = None | ||
|
|
||
| if prod_environment_id: | ||
| for environment in environments: | ||
| if environment.id == prod_environment_id: | ||
| prod_environment = DbtPlatformEnvironment( | ||
| id=environment.id, | ||
| name=environment.name, | ||
| deployment_type=environment.deployment_type or "production", | ||
| ) | ||
| break | ||
| else: | ||
| for environment in environments: | ||
| if ( | ||
| environment.deployment_type | ||
| and environment.deployment_type.lower() == "production" | ||
| ): | ||
| prod_environment = DbtPlatformEnvironment( | ||
| id=environment.id, | ||
| name=environment.name, | ||
| deployment_type=environment.deployment_type, | ||
| ) | ||
| break | ||
|
|
||
| for environment in environments: | ||
| if ( | ||
| environment.deployment_type | ||
| and environment.deployment_type.lower() == "development" | ||
| ): | ||
| dev_environment = DbtPlatformEnvironment( | ||
| id=environment.id, | ||
| name=environment.name, | ||
| deployment_type=environment.deployment_type, | ||
| ) | ||
| break | ||
|
|
||
| return prod_environment, dev_environment | ||
|
|
||
|
|
||
| async def get_environments_for_project( | ||
| *, | ||
| dbt_platform_url: str, | ||
| account_id: int, | ||
| project_id: int, | ||
| headers: dict[str, str], | ||
| prod_environment_id: int | None = None, | ||
| ) -> tuple[DbtPlatformEnvironment | None, DbtPlatformEnvironment | None]: | ||
| """Fetch environments for a project and resolve prod/dev. | ||
| Returns a tuple of (prod_environment, dev_environment). | ||
| """ | ||
| environments = await _get_all_environments_for_project( | ||
| dbt_platform_url=dbt_platform_url, | ||
| account_id=account_id, | ||
| project_id=project_id, | ||
| headers=headers, | ||
| ) | ||
| return resolve_environments( | ||
| environments, | ||
| prod_environment_id=prod_environment_id, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import logging | ||
|
|
||
| import httpx | ||
|
|
||
| from dbt_mcp.oauth.dbt_platform import ( | ||
| DbtPlatformAccount, | ||
| DbtPlatformProject, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def get_all_accounts( | ||
| *, | ||
| dbt_platform_url: str, | ||
| headers: dict[str, str], | ||
| ) -> list[DbtPlatformAccount]: | ||
| async with httpx.AsyncClient() as client: | ||
| response = await client.get( | ||
| url=f"{dbt_platform_url}/api/v3/accounts/", | ||
| headers=headers, | ||
| ) | ||
| response.raise_for_status() | ||
| data = response.json() | ||
| return [DbtPlatformAccount(**account) for account in data["data"]] | ||
|
|
||
|
|
||
| async def get_all_projects_for_account( | ||
| *, | ||
| dbt_platform_url: str, | ||
| account: DbtPlatformAccount, | ||
| headers: dict[str, str], | ||
| page_size: int = 100, | ||
| ) -> list[DbtPlatformProject]: | ||
| """Fetch all projects for an account using offset/page_size pagination.""" | ||
| offset = 0 | ||
| projects: list[DbtPlatformProject] = [] | ||
| async with httpx.AsyncClient() as client: | ||
| while True: | ||
| response = await client.get( | ||
| f"{dbt_platform_url}/api/v3/accounts/{account.id}/projects/?state=1&offset={offset}&limit={page_size}", | ||
| headers=headers, | ||
| ) | ||
| response.raise_for_status() | ||
| page = response.json()["data"] | ||
| projects.extend( | ||
| DbtPlatformProject(**project, account_name=account.name) | ||
| for project in page | ||
| ) | ||
| if len(page) < page_size: | ||
| break | ||
| offset += page_size | ||
| return projects |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Check failure
Code scanning / CodeQL
Partial server-side request forgery
Copilot Autofix
AI about 22 hours ago
In general, to fix partial SSRF in this pattern you constrain user-controlled path components to a safe, expected subset and/or verify that they refer to resources the caller is actually allowed to access. For numeric IDs, that means enforcing integer types and checking that the requested IDs are present in the set of accounts/projects associated with the authenticated user, instead of blindly passing any ID into a backend HTTP call.
Concretely here, the vulnerable pieces are the
account_id/project_idvalues passed into_get_all_environments_for_projectfromget_deployment_environmentsandset_selected_project. The best fix without changing intended functionality is to validate that the requestedaccount_idcorresponds to one of the accounts returned byget_all_accountsfor the current token, and that theproject_idcorresponds to one of the projects for that account fromget_all_projects_for_account. If either does not exist, we return a 4xx-style error (by raising aValueErroras is already done for a missing account inset_selected_project). This both satisfies CodeQL (the URL path still uses user-supplied IDs but now only after authorization/validation) and enforces proper access control.Specifically:
get_deployment_environments(/environmentshandler):headers, callget_all_accounts(dbt_platform_url, headers)and ensurerequest.account_idis present. If not, raiseValueError.get_all_projects_for_accountfor that account and ensurerequest.project_idexists. If not, raiseValueError._get_all_environments_for_projectwith those IDs.set_selected_project(/selected_projecthandler):selected_project_request.project_idby callingget_all_projects_for_accountwith the resolvedaccountand verifying the project exists before calling_get_all_environments_for_project.All of these changes occur in
src/dbt_mcp/oauth/fastapi_app.py. We do not need to modify_get_all_environments_for_projectinenvironment_resolver.py; it continues to accept IDs but will only be called with IDs that have been vetted against the user’s accessible resources. No new imports are required, as we already importget_all_accountsandget_all_projects_for_account.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a false positive.