diff --git a/infisical_sdk/api_types.py b/infisical_sdk/api_types.py index 7528a35..80eb8f1 100644 --- a/infisical_sdk/api_types.py +++ b/infisical_sdk/api_types.py @@ -615,6 +615,7 @@ class CreatedAgentVaultSession(BaseModel): id: str token: str createdAt: str + projectId: Optional[str] = None label: Optional[str] = None expiresAt: Optional[str] = None accessBundle: Optional[AgentVaultSessionAccessBundle] = None diff --git a/infisical_sdk/infisical_requests.py b/infisical_sdk/infisical_requests.py index 5d75363..bbb29fb 100644 --- a/infisical_sdk/infisical_requests.py +++ b/infisical_sdk/infisical_requests.py @@ -184,7 +184,8 @@ def post( self, path: str, model: Type[T], - json: Optional[Dict[str, Any]] = None + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None ) -> APIResponse[T]: """Make a POST request with JSON data""" @@ -193,7 +194,7 @@ def post( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.post(self._build_url(path), json=json) + response = self.session.post(self._build_url(path), json=json, params=params) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -209,7 +210,8 @@ def post_once( self, path: str, model: Type[T], - json: Optional[Dict[str, Any]] = None + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None ) -> APIResponse[T]: """Make a POST request that must not run twice, such as one that creates something. @@ -219,7 +221,7 @@ def post_once( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.post(self._build_url(path), json=json) + response = self.session.post(self._build_url(path), json=json, params=params) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -261,7 +263,8 @@ def put( self, path: str, model: Type[T], - json: Optional[Dict[str, Any]] = None + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None ) -> APIResponse[T]: """Make a PUT request with JSON data. A PUT replaces the whole resource, so it's safe to retry.""" @@ -270,7 +273,7 @@ def put( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.put(self._build_url(path), json=json) + response = self.session.put(self._build_url(path), json=json, params=params) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -286,7 +289,8 @@ def delete( self, path: str, model: Type[T], - json: Optional[Dict[str, Any]] = None + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None ) -> APIResponse[T]: """Make a DELETE request with JSON data""" @@ -295,7 +299,7 @@ def delete( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.delete(self._build_url(path), json=json) + response = self.session.delete(self._build_url(path), json=json, params=params) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data @@ -311,7 +315,8 @@ def delete_once( self, path: str, model: Type[T], - json: Optional[Dict[str, Any]] = None + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None ) -> APIResponse[T]: """Make a DELETE request that must not run twice. It's retried only in the same cases as post_once.""" @@ -320,7 +325,7 @@ def delete_once( # Filter out None values json = {k: v for k, v in json.items() if v is not None} - response = self.session.delete(self._build_url(path), json=json) + response = self.session.delete(self._build_url(path), json=json, params=params) data = self._handle_response(response) parsed_data = model.from_dict(data) if hasattr(model, 'from_dict') else data diff --git a/infisical_sdk/resources/agent_vault.py b/infisical_sdk/resources/agent_vault.py index 5ebc411..8069ad8 100644 --- a/infisical_sdk/resources/agent_vault.py +++ b/infisical_sdk/resources/agent_vault.py @@ -88,8 +88,10 @@ def list( order_by: str = "createdAt", order_direction: str = "desc", limit: int = 20, - offset: int = 0) -> ListAgentVaultProxiesResponse: - """List the organization's Agent Vault proxies. + offset: int = 0, + *, + project_id: str) -> ListAgentVaultProxiesResponse: + """List an Agent Vault project's proxies. Args: search: Match proxies by name or description. @@ -97,6 +99,7 @@ def list( order_direction: Which way to sort: "asc" or "desc". Defaults to "desc". limit: The maximum number of proxies to return (1-100). Defaults to 20. offset: How many proxies to skip. Defaults to 0. + project_id: The ID of the Agent Vault project to list proxies from. Returns: ListAgentVaultProxiesResponse containing proxies and totalCount. Settings fields are only set for admins. @@ -107,6 +110,7 @@ def list( "orderDirection": order_direction, "limit": limit, "offset": offset, + "projectId": project_id, } result = self.requests.get( @@ -123,7 +127,9 @@ def create( traffic_policy: Union[AgentVaultTrafficPolicy, str] = AgentVaultTrafficPolicy.ANY_HOST, allowed_hosts: Optional[Union[str, List[str]]] = None, poll_interval: int = 60, - description: Optional[str] = None) -> AgentVaultProxyWithTokenResponse: + description: Optional[str] = None, + *, + project_id: str) -> AgentVaultProxyWithTokenResponse: """Register an Agent Vault proxy and issue its proxy token. Args: @@ -132,6 +138,7 @@ def create( allowed_hosts: Hosts that stay reachable under the "service-hosts" traffic policy even though none of the session's services cover them. A list or a comma-separated string. poll_interval: How often, in seconds, the proxy refreshes its sessions and settings (10-300). Defaults to 60. description: What the proxy is for, up to 256 characters, without line breaks or other control characters. + project_id: The ID of the Agent Vault project to register the proxy in. Returns: AgentVaultProxyWithTokenResponse containing the proxy and its proxyToken. The token is only returned once. @@ -142,6 +149,7 @@ def create( "allowedHosts": _join_hosts(allowed_hosts), "pollInterval": poll_interval, "description": description, + "projectId": project_id, } result = self.requests.post_once( @@ -257,7 +265,9 @@ def list( statuses: Optional[List[Union[AgentVaultSessionStatus, str]]] = None, search: Optional[str] = None, limit: int = 20, - offset: int = 0) -> ListAgentVaultSessionsResponse: + offset: int = 0, + *, + project_id: str) -> ListAgentVaultSessionsResponse: """List Agent Vault sessions. Args: @@ -266,6 +276,7 @@ def list( search: Match sessions by label, actor name, actor email, the name or email of the user a session was started on behalf of, or access bundle name. limit: The maximum number of sessions to return (1-100). Defaults to 20. offset: How many sessions to skip. Defaults to 0. + project_id: The ID of the Agent Vault project to list sessions from. Returns: ListAgentVaultSessionsResponse containing sessions and totalCount. @@ -280,6 +291,7 @@ def list( "search": search, "limit": limit, "offset": offset, + "projectId": project_id, } result = self.requests.get( @@ -315,7 +327,9 @@ def create( label: Optional[str] = None, on_behalf_of_user_email: Optional[str] = None, on_behalf_of_user_id: Optional[str] = None, - services: Optional[List[Dict[str, Any]]] = None) -> CreatedAgentVaultSession: + services: Optional[List[Dict[str, Any]]] = None, + *, + project_id: str) -> CreatedAgentVaultSession: """Create an Agent Vault session with an access bundle you can reach, services of its own, or both, for yourself or, as a machine identity with a delegation, on behalf of a user. Only Agent Vault admins can send services with a session. On behalf of a user, that's your machine identity: @@ -333,6 +347,7 @@ def create( on_behalf_of_user_email: The email address of the user to start the session on behalf of. Can't be combined with on_behalf_of_user_id. on_behalf_of_user_id: The ID of the user to start the session on behalf of. Can't be combined with on_behalf_of_user_email. services: Services the session carries itself, instead of or beside its access bundle's: up to 20 dicts with the API's field names, such as {"type": "http", "hostPattern": "api.github.com", "credential": {"type": "bearer", "value": token}}. Only Agent Vault admins can send them. Each has the same fields as a service in an access bundle, except that its name is optional, its values can't use variables, it can't use OAuth or member-provided values, and an MCP server can't be limited to some of its tools. To limit an MCP server's tools, add it to an access bundle instead. + project_id: The ID of the Agent Vault project to create the session in. Returns: The created session, including its token. The token is only returned once. Its services leave out their tokens, passwords and values. @@ -352,6 +367,7 @@ def create( "ttl": ttl, "label": label, "onBehalfOf": on_behalf_of, + "projectId": project_id, } result = self.requests.post_once( @@ -418,7 +434,9 @@ def list( order_by: str = "createdAt", order_direction: str = "desc", limit: int = 20, - offset: int = 0) -> ListAgentVaultAccessBundlesResponse: + offset: int = 0, + *, + project_id: str) -> ListAgentVaultAccessBundlesResponse: """List the Agent Vault access bundles you can reach. Admins reach every access bundle. Args: @@ -431,6 +449,7 @@ def list( order_direction: Which way to sort: "asc" or "desc". Defaults to "desc". limit: The maximum number of access bundles to return (1-100). Defaults to 20. offset: How many access bundles to skip. Defaults to 0. + project_id: The ID of the Agent Vault project to list access bundles from. Returns: ListAgentVaultAccessBundlesResponse containing accessBundles and totalCount. @@ -447,6 +466,7 @@ def list( "orderDirection": order_direction, "limit": limit, "offset": offset, + "projectId": project_id, } result = self.requests.get( @@ -473,12 +493,18 @@ def get(self, access_bundle_id: str) -> AgentVaultAccessBundle: return result.data.accessBundle - def create(self, name: str, description: Optional[str] = None) -> AgentVaultAccessBundle: + def create( + self, + name: str, + description: Optional[str] = None, + *, + project_id: str) -> AgentVaultAccessBundle: """Create an Agent Vault access bundle. It's granted to you, unless you're in Agent Vault only through a group. Args: name: The name of the access bundle (a slug, at most 64 characters). description: What the access bundle is for, up to 256 characters, without line breaks or other control characters. + project_id: The ID of the Agent Vault project to create the access bundle in. Returns: The created access bundle. @@ -486,6 +512,7 @@ def create(self, name: str, description: Optional[str] = None) -> AgentVaultAcce request_body = { "name": name, "description": description, + "projectId": project_id, } result = self.requests.post_once( @@ -678,7 +705,9 @@ def revoke( class AgentVaultDelegations: - """Manages Agent Vault delegations, which let machine identities create sessions on behalf of users. Every method requires the Admin role in Agent Vault, except that a machine identity can get its own delegation.""" + """Manages Agent Vault delegations, which let machine identities create sessions on behalf of users. Every method requires the Admin role in Agent Vault, except that a machine identity can get its own delegation. + + A machine identity can be a member of several Agent Vault projects and has a separate delegation in each, so every method takes project_id.""" def __init__(self, requests: InfisicalRequests) -> None: self.requests = requests @@ -687,13 +716,16 @@ def list( self, machine_identity_ids: Optional[List[str]] = None, limit: int = 100, - offset: int = 0) -> ListAgentVaultDelegationsResponse: + offset: int = 0, + *, + project_id: str) -> ListAgentVaultDelegationsResponse: """List the machine identities that can create Agent Vault sessions on behalf of users. Args: machine_identity_ids: Only return the delegations of these machine identities, up to 100. limit: The maximum number of delegations to return (1-100). Defaults to 100. offset: How many delegations to skip. Defaults to 0. + project_id: The ID of the Agent Vault project to list delegations from. Returns: ListAgentVaultDelegationsResponse containing delegations and totalCount. missingAccessBundles isn't set on these delegations; call get for it. @@ -702,6 +734,7 @@ def list( "machineIdentityIds": ",".join(machine_identity_ids) if machine_identity_ids else None, "limit": limit, "offset": offset, + "projectId": project_id, } result = self.requests.get( @@ -712,17 +745,19 @@ def list( return result.data - def get(self, machine_identity_id: str) -> AgentVaultDelegation: + def get(self, machine_identity_id: str, *, project_id: str) -> AgentVaultDelegation: """Get which users a machine identity can create Agent Vault sessions on behalf of. Raises an APIError with status 404 if the machine identity has no delegation. Args: machine_identity_id: The ID of the machine identity. + project_id: The ID of the Agent Vault project the delegation belongs to. Returns: The delegation. missingAccessBundles lists the access bundles that the users it covers can use and the machine identity can't, so the machine identity can't create sessions over them. They're empty when a machine identity that isn't an admin gets its own delegation. """ result = self.requests.get( path=f"/api/v1/agent-vault/delegations/{_segment(machine_identity_id)}", + params={"projectId": project_id}, model=SingleAgentVaultDelegationResponse ) @@ -733,7 +768,9 @@ def set( machine_identity_id: str, scope: Union[AgentVaultDelegationScope, str], user_ids: Optional[List[str]] = None, - group_ids: Optional[List[str]] = None) -> AgentVaultDelegation: + group_ids: Optional[List[str]] = None, + *, + project_id: str) -> AgentVaultDelegation: """Set which users a machine identity can create Agent Vault sessions on behalf of, replacing any delegation it has. Args: @@ -741,6 +778,7 @@ def set( scope: AgentVaultDelegationScope.ALL_MEMBERS ("all-members") for every Agent Vault member, admins included, or AgentVaultDelegationScope.SELECTED ("selected") for only the users and groups you name. user_ids: With the "selected" scope, the users the machine identity can act on behalf of, up to 100. Each must be a member of Agent Vault. For a user who's in Agent Vault only through a group, name the group instead. group_ids: With the "selected" scope, the groups whose members the machine identity can act on behalf of, up to 100. Each must be a member of Agent Vault. + project_id: The ID of the Agent Vault project the delegation belongs to. Returns: The saved delegation, with missingAccessBundles. @@ -754,16 +792,18 @@ def set( result = self.requests.put( path=f"/api/v1/agent-vault/delegations/{_segment(machine_identity_id)}", json=request_body, + params={"projectId": project_id}, model=SingleAgentVaultDelegationResponse ) return result.data.delegation - def delete(self, machine_identity_id: str) -> AgentVaultDelegation: + def delete(self, machine_identity_id: str, *, project_id: str) -> AgentVaultDelegation: """Stop a machine identity from creating Agent Vault sessions on behalf of users. The proxy refuses the sessions it already created on their behalf until a delegation covers those users again, so revoke the sessions to end them for good. Args: machine_identity_id: The ID of the machine identity. + project_id: The ID of the Agent Vault project the delegation belongs to. Returns: The deleted delegation. missingAccessBundles isn't set. @@ -772,6 +812,7 @@ def delete(self, machine_identity_id: str) -> AgentVaultDelegation: # 404 for a delete that worked. result = self.requests.delete_once( path=f"/api/v1/agent-vault/delegations/{_segment(machine_identity_id)}", + params={"projectId": project_id}, model=SingleAgentVaultDelegationResponse ) @@ -781,13 +822,16 @@ def add_subjects( self, machine_identity_id: str, user_ids: Optional[List[str]] = None, - group_ids: Optional[List[str]] = None) -> AgentVaultDelegation: + group_ids: Optional[List[str]] = None, + *, + project_id: str) -> AgentVaultDelegation: """Add users and groups to a machine identity's delegation, keeping the ones it already names. If the machine identity has no delegation, this creates one with the "selected" scope. A delegation to all members can't take users or groups. Args: machine_identity_id: The ID of the machine identity. user_ids: The users to add, up to 100. Each must be a member of Agent Vault. Users the delegation already names are skipped. group_ids: The groups to add, up to 100. Each must be a member of Agent Vault. Groups the delegation already names are skipped. + project_id: The ID of the Agent Vault project the delegation belongs to. Returns: The delegation after the change, with missingAccessBundles. @@ -800,6 +844,7 @@ def add_subjects( result = self.requests.post( path=f"/api/v1/agent-vault/delegations/{_segment(machine_identity_id)}/subjects", json=request_body, + params={"projectId": project_id}, model=SingleAgentVaultDelegationResponse ) @@ -809,13 +854,16 @@ def remove_subjects( self, machine_identity_id: str, user_ids: Optional[List[str]] = None, - group_ids: Optional[List[str]] = None) -> Optional[AgentVaultDelegation]: + group_ids: Optional[List[str]] = None, + *, + project_id: str) -> Optional[AgentVaultDelegation]: """Take users and groups out of a machine identity's delegation. The proxy refuses the sessions it created on behalf of the users you take out until the delegation covers them again, so revoke the sessions to end them for good. Taking out the last user or group removes the delegation. Args: machine_identity_id: The ID of the machine identity. user_ids: The users to take out, up to 100. Users the delegation doesn't name are skipped. group_ids: The groups to take out, up to 100. Groups the delegation doesn't name are skipped. + project_id: The ID of the Agent Vault project the delegation belongs to. Returns: The delegation after the change, with missingAccessBundles, or None if it no longer names anyone. @@ -830,6 +878,7 @@ def remove_subjects( result = self.requests.delete_once( path=f"/api/v1/agent-vault/delegations/{_segment(machine_identity_id)}/subjects", json=request_body, + params={"projectId": project_id}, model=SingleAgentVaultDelegationResponse ) @@ -841,7 +890,9 @@ def list_available_subjects( actor_type: Optional[Union[AgentVaultMemberType, str]] = None, search: Optional[str] = None, limit: int = 20, - offset: int = 0) -> ListAgentVaultMembersResponse: + offset: int = 0, + *, + project_id: str) -> ListAgentVaultMembersResponse: """List the Agent Vault users and groups that can still be added to a machine identity's delegation. Args: @@ -850,6 +901,7 @@ def list_available_subjects( search: Match users by name, username or email address, and groups by name. limit: The maximum number of users and groups to return (1-100). Defaults to 20. offset: How many users and groups to skip. Defaults to 0. + project_id: The ID of the Agent Vault project the delegation belongs to. Returns: ListAgentVaultMembersResponse containing the users and groups in members, and totalCount. @@ -859,6 +911,7 @@ def list_available_subjects( "search": search, "limit": limit, "offset": offset, + "projectId": project_id, } result = self.requests.get( diff --git a/tests/test_agent_vault.py b/tests/test_agent_vault.py index c8bc9f4..99c3f9d 100644 --- a/tests/test_agent_vault.py +++ b/tests/test_agent_vault.py @@ -56,6 +56,7 @@ "customHeaders": [{"name": "X-Team", "prefix": ""}], "substitutions": [], } +PROJECT_ID = "4d6f8a1c-2e3b-4c5d-9e7f-1a2b3c4d5e6f" MACHINE_IDENTITY_ID = "9c4b2a1d-3e5f-4a6b-8c7d-2e1f0a9b8c7d" GROUP_ID = "7b1e4f2a-9c3d-4a6b-8e5f-1d2c3b4a5e6f" SERVICE_ID = "3a5c7e9b-2d4f-4a6c-8e1b-5d7f9a2c4e6b" @@ -93,7 +94,7 @@ def test_proxies_update_leaves_out_values_you_do_not_pass(api, agent_vault, json def test_proxies_create_sends_and_reads_the_description(api, agent_vault, json_response): api.session.post.return_value = json_response({"proxy": dict(PROXY, description="Edge traffic"), "proxyToken": "token"}) - created = agent_vault.proxies.create(name="edge-proxy", description="Edge traffic") + created = agent_vault.proxies.create(name="edge-proxy", description="Edge traffic", project_id=PROJECT_ID) assert api.session.post.call_args.kwargs["json"]["description"] == "Edge traffic" assert created.proxy.description == "Edge traffic" @@ -116,9 +117,9 @@ def test_sessions_update_always_sends_the_label(api, agent_vault, json_response, @pytest.mark.parametrize("create", [ - lambda agent_vault: agent_vault.sessions.create(access_bundle="release"), - lambda agent_vault: agent_vault.proxies.create(name="edge-proxy"), - lambda agent_vault: agent_vault.access_bundles.create(name="release"), + lambda agent_vault: agent_vault.sessions.create(access_bundle="release", project_id=PROJECT_ID), + lambda agent_vault: agent_vault.proxies.create(name="edge-proxy", project_id=PROJECT_ID), + lambda agent_vault: agent_vault.access_bundles.create(name="release", project_id=PROJECT_ID), ], ids=["sessions.create", "proxies.create", "access_bundles.create"]) def test_creates_are_not_retried_once_the_request_may_have_reached_the_server(api, agent_vault, create): api.session.post.side_effect = requests.exceptions.ReadTimeout() @@ -130,7 +131,7 @@ def test_creates_are_not_retried_once_the_request_may_have_reached_the_server(ap @pytest.mark.parametrize("allowed_hosts", [[], ""], ids=["empty list", "empty string"]) def test_proxies_create_leaves_out_empty_allowed_hosts(api, agent_vault, json_response, allowed_hosts): api.session.post.return_value = json_response({"proxy": PROXY, "proxyToken": "token"}) - agent_vault.proxies.create(name="edge-proxy", allowed_hosts=allowed_hosts) + agent_vault.proxies.create(name="edge-proxy", allowed_hosts=allowed_hosts, project_id=PROJECT_ID) assert "allowedHosts" not in api.session.post.call_args.kwargs["json"] @@ -163,15 +164,21 @@ def test_sessions_get_keeps_the_name_of_a_deleted_access_bundle(api, agent_vault def test_sessions_create_sends_one_access_bundle(api, agent_vault, json_response): created = {"id": SESSION["id"], "token": "agv_token", "createdAt": SESSION["createdAt"], "accessBundle": SESSION["accessBundle"]} api.session.post.return_value = json_response({"session": created}) - session = agent_vault.sessions.create(access_bundle="release", ttl="1h") - assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "ttl": "1h"} + session = agent_vault.sessions.create(access_bundle="release", ttl="1h", project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "ttl": "1h", "projectId": PROJECT_ID} assert session.accessBundle.name == "release" + +def test_sessions_create_reads_the_project_the_session_belongs_to(api, agent_vault, json_response): + created = {"id": SESSION["id"], "projectId": PROJECT_ID, "token": "agv_token", "createdAt": SESSION["createdAt"]} + api.session.post.return_value = json_response({"session": created}) + assert agent_vault.sessions.create(access_bundle="release", project_id=PROJECT_ID).projectId == PROJECT_ID + def test_sessions_create_leaves_out_ttl_so_the_server_picks_the_default(api, agent_vault, json_response): api.session.post.return_value = json_response({"session": dict(SESSION, token="token")}) - agent_vault.sessions.create(access_bundle="release") - assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release"} + agent_vault.sessions.create(access_bundle="release", project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "projectId": PROJECT_ID} @pytest.mark.parametrize("kwargs, on_behalf_of", [ @@ -181,14 +188,14 @@ def test_sessions_create_leaves_out_ttl_so_the_server_picks_the_default(api, age ], ids=["email", "id", "empty email"]) def test_sessions_create_names_the_user_it_acts_for(api, agent_vault, json_response, kwargs, on_behalf_of): api.session.post.return_value = json_response({"session": dict(SESSION, token="token")}) - agent_vault.sessions.create(access_bundle="release", **kwargs) - assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "onBehalfOf": on_behalf_of} + agent_vault.sessions.create(access_bundle="release", project_id=PROJECT_ID, **kwargs) + assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "onBehalfOf": on_behalf_of, "projectId": PROJECT_ID} def test_sessions_create_still_takes_the_access_bundle_first(api, agent_vault, json_response): api.session.post.return_value = json_response({"session": dict(SESSION, token="token")}) - agent_vault.sessions.create("release", "1h") - assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "ttl": "1h"} + agent_vault.sessions.create("release", "1h", project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "ttl": "1h", "projectId": PROJECT_ID} def test_sessions_create_rejects_both_a_user_email_and_a_user_id(api, agent_vault): @@ -197,6 +204,7 @@ def test_sessions_create_rejects_both_a_user_email_and_a_user_id(api, agent_vaul access_bundle="release", on_behalf_of_user_email=ON_BEHALF_OF["email"], on_behalf_of_user_id=ON_BEHALF_OF["id"], + project_id=PROJECT_ID, ) api.session.post.assert_not_called() @@ -211,21 +219,21 @@ def test_sessions_get_reads_the_user_a_session_acts_for(api, agent_vault, json_r def test_access_bundles_list_sends_booleans_the_way_the_api_reads_them(api, agent_vault, json_response): api.session.get.return_value = json_response({"accessBundles": [], "totalCount": 0}) - agent_vault.access_bundles.list(requires_setup=True, granted=False) + agent_vault.access_bundles.list(requires_setup=True, granted=False, project_id=PROJECT_ID) params = api.session.get.call_args.kwargs["params"] assert (params["requiresSetup"], params["granted"]) == ("true", "false") def test_access_bundles_list_names_the_user_a_session_would_act_for(api, agent_vault, json_response): api.session.get.return_value = json_response({"accessBundles": [], "totalCount": 0}) - agent_vault.access_bundles.list(on_behalf_of_user_email=ON_BEHALF_OF["email"]) + agent_vault.access_bundles.list(on_behalf_of_user_email=ON_BEHALF_OF["email"], project_id=PROJECT_ID) params = api.session.get.call_args.kwargs["params"] assert (params["onBehalfOfUserEmail"], params["onBehalfOfUserId"]) == (ON_BEHALF_OF["email"], None) def test_access_bundles_list_rejects_both_a_user_email_and_a_user_id(api, agent_vault): with pytest.raises(ValueError, match="not both"): - agent_vault.access_bundles.list(on_behalf_of_user_email=ON_BEHALF_OF["email"], on_behalf_of_user_id=ON_BEHALF_OF["id"]) + agent_vault.access_bundles.list(on_behalf_of_user_email=ON_BEHALF_OF["email"], on_behalf_of_user_id=ON_BEHALF_OF["id"], project_id=PROJECT_ID) api.session.get.assert_not_called() @@ -233,7 +241,7 @@ def test_access_bundles_list_reads_the_services_requiring_setup(api, agent_vault listed = dict(BUNDLE, serviceCount=1, memberCount=2, hostPatterns=["api.github.com"], servicesRequiringSetup=[{"id": SERVICE_ID, "name": "github"}]) api.session.get.return_value = json_response({"accessBundles": [listed], "totalCount": 1}) - bundle = agent_vault.access_bundles.list().accessBundles[0] + bundle = agent_vault.access_bundles.list(project_id=PROJECT_ID).accessBundles[0] assert [(service.id, service.name) for service in bundle.servicesRequiringSetup] == [(SERVICE_ID, "github")] assert bundle.services is None @@ -269,13 +277,13 @@ def test_access_bundles_grant_and_revoke_send_only_the_grantees_you_name(api, ag def test_delegations_set_puts_the_scope_value(api, agent_vault, json_response): api.session.put.return_value = json_response({"delegation": DELEGATION}) - agent_vault.delegations.set(MACHINE_IDENTITY_ID, AgentVaultDelegationScope.SELECTED, group_ids=[GROUP_ID]) + agent_vault.delegations.set(MACHINE_IDENTITY_ID, AgentVaultDelegationScope.SELECTED, group_ids=[GROUP_ID], project_id=PROJECT_ID) assert api.session.put.call_args.kwargs["json"] == {"scope": "selected", "groupIds": [GROUP_ID]} def test_delegations_get_reads_users_groups_and_missing_access_bundles(api, agent_vault, json_response): api.session.get.return_value = json_response({"delegation": DELEGATION}) - delegation = agent_vault.delegations.get(MACHINE_IDENTITY_ID) + delegation = agent_vault.delegations.get(MACHINE_IDENTITY_ID, project_id=PROJECT_ID) assert delegation.machineIdentity.name == "slack-bot" assert [bundle.name for bundle in delegation.users[0].missingAccessBundles] == ["release"] assert (delegation.groups[0].name, delegation.groups[0].missingAccessBundles) == ("Engineering", []) @@ -285,7 +293,7 @@ def test_delegations_list_joins_the_machine_identity_ids(api, agent_vault, json_ other_machine_identity_id = "2f6e8a4c-1b3d-4c5e-9f7a-6b8c0d2e4f1a" listed = {key: value for key, value in DELEGATION.items() if key != "missingAccessBundles"} api.session.get.return_value = json_response({"delegations": [listed], "totalCount": 1}) - delegations = agent_vault.delegations.list(machine_identity_ids=[MACHINE_IDENTITY_ID, other_machine_identity_id]) + delegations = agent_vault.delegations.list(machine_identity_ids=[MACHINE_IDENTITY_ID, other_machine_identity_id], project_id=PROJECT_ID) expected = f"{MACHINE_IDENTITY_ID},{other_machine_identity_id}" assert api.session.get.call_args.kwargs["params"]["machineIdentityIds"] == expected assert delegations.delegations[0].missingAccessBundles is None @@ -293,13 +301,13 @@ def test_delegations_list_joins_the_machine_identity_ids(api, agent_vault, json_ def test_delegations_remove_subjects_returns_none_once_the_last_one_is_gone(api, agent_vault, json_response): api.session.delete.return_value = json_response({"delegation": None}) - assert agent_vault.delegations.remove_subjects(MACHINE_IDENTITY_ID, user_ids=[ON_BEHALF_OF["id"]]) is None + assert agent_vault.delegations.remove_subjects(MACHINE_IDENTITY_ID, user_ids=[ON_BEHALF_OF["id"]], project_id=PROJECT_ID) is None assert api.session.delete.call_args.kwargs["json"] == {"userIds": [ON_BEHALF_OF["id"]]} def test_delegations_list_available_subjects_sends_the_actor_type_value(api, agent_vault, json_response): api.session.get.return_value = json_response({"members": [], "totalCount": 0}) - agent_vault.delegations.list_available_subjects(MACHINE_IDENTITY_ID, actor_type=AgentVaultMemberType.GROUP) + agent_vault.delegations.list_available_subjects(MACHINE_IDENTITY_ID, actor_type=AgentVaultMemberType.GROUP, project_id=PROJECT_ID) assert api.session.get.call_args.kwargs["params"]["actorType"] == "group" @@ -310,7 +318,7 @@ def test_proxies_send_either_name_of_service_hosts_as_bundle_hosts_which_every_v api, agent_vault, json_response, traffic_policy): api.session.post.return_value = json_response({"proxy": PROXY, "proxyToken": "token"}) api.session.patch.return_value = json_response({"proxy": PROXY}) - agent_vault.proxies.create(name="edge-proxy", traffic_policy=traffic_policy) + agent_vault.proxies.create(name="edge-proxy", traffic_policy=traffic_policy, project_id=PROJECT_ID) agent_vault.proxies.update(PROXY["id"], traffic_policy=traffic_policy) assert api.session.post.call_args.kwargs["json"]["trafficPolicy"] == "bundle-hosts" assert api.session.patch.call_args.kwargs["json"] == {"trafficPolicy": "bundle-hosts"} @@ -320,15 +328,15 @@ def test_proxies_send_either_name_of_service_hosts_as_bundle_hosts_which_every_v ids=["default", "ANY_HOST", "any-host"]) def test_proxies_create_sends_any_host_as_it_is(api, agent_vault, json_response, kwargs): api.session.post.return_value = json_response({"proxy": PROXY, "proxyToken": "token"}) - agent_vault.proxies.create(name="edge-proxy", **kwargs) + agent_vault.proxies.create(name="edge-proxy", project_id=PROJECT_ID, **kwargs) assert api.session.post.call_args.kwargs["json"]["trafficPolicy"] == "any-host" @pytest.mark.parametrize("reported", ["service-hosts", "bundle-hosts"], ids=["current version", "version before the rename"]) @pytest.mark.parametrize("verb, payload, call", [ - ("get", lambda proxy: {"proxies": [proxy], "totalCount": 1}, lambda agent_vault: agent_vault.proxies.list().proxies[0]), + ("get", lambda proxy: {"proxies": [proxy], "totalCount": 1}, lambda agent_vault: agent_vault.proxies.list(project_id=PROJECT_ID).proxies[0]), ("post", lambda proxy: {"proxy": proxy, "proxyToken": "token"}, - lambda agent_vault: agent_vault.proxies.create(name="edge-proxy").proxy), + lambda agent_vault: agent_vault.proxies.create(name="edge-proxy", project_id=PROJECT_ID).proxy), ("patch", lambda proxy: {"proxy": proxy}, lambda agent_vault: agent_vault.proxies.update(PROXY["id"], poll_interval=30)), ("post", lambda proxy: {"proxy": proxy, "proxyToken": "token"}, lambda agent_vault: agent_vault.proxies.rotate_token(PROXY["id"]).proxy), @@ -344,7 +352,7 @@ def test_proxies_report_either_name_as_service_hosts(api, agent_vault, json_resp def test_proxies_list_leaves_the_traffic_policy_unset_for_a_member(api, agent_vault, json_response): api.session.get.return_value = json_response({"proxies": [PROXY], "totalCount": 1}) - assert agent_vault.proxies.list().proxies[0].trafficPolicy is None + assert agent_vault.proxies.list(project_id=PROJECT_ID).proxies[0].trafficPolicy is None def test_traffic_policy_old_name_is_the_same_member(): @@ -358,20 +366,20 @@ def test_traffic_policy_old_name_is_the_same_member(): def test_sessions_create_sends_services_without_an_access_bundle(api, agent_vault, json_response): api.session.post.return_value = json_response({"session": dict(SESSION, token="token", services=[INLINE_SERVICE])}) - agent_vault.sessions.create(services=[INLINE_SERVICE_INPUT]) - assert api.session.post.call_args.kwargs["json"] == {"services": [INLINE_SERVICE_INPUT]} + agent_vault.sessions.create(services=[INLINE_SERVICE_INPUT], project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"] == {"services": [INLINE_SERVICE_INPUT], "projectId": PROJECT_ID} def test_sessions_create_sends_an_access_bundle_and_services_together(api, agent_vault, json_response): api.session.post.return_value = json_response({"session": dict(SESSION, token="token", services=[INLINE_SERVICE])}) - agent_vault.sessions.create(access_bundle="release", services=[INLINE_SERVICE_INPUT]) - assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "services": [INLINE_SERVICE_INPUT]} + agent_vault.sessions.create(access_bundle="release", services=[INLINE_SERVICE_INPUT], project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "release", "services": [INLINE_SERVICE_INPUT], "projectId": PROJECT_ID} def test_sessions_create_sends_an_empty_access_bundle_name_so_the_server_rejects_it(api, agent_vault, json_response): api.session.post.return_value = json_response({"session": dict(SESSION, token="token")}) - agent_vault.sessions.create(access_bundle="") - assert api.session.post.call_args.kwargs["json"] == {"accessBundle": ""} + agent_vault.sessions.create(access_bundle="", project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"] == {"accessBundle": "", "projectId": PROJECT_ID} def test_sessions_read_services_as_the_api_sends_them(api, agent_vault, json_response): @@ -382,9 +390,9 @@ def test_sessions_read_services_as_the_api_sends_them(api, agent_vault, json_res json_response({"sessions": [session], "totalCount": 1}), ] expected = [AgentVaultInlineService(**INLINE_SERVICE)] - assert agent_vault.sessions.create(services=[INLINE_SERVICE_INPUT]).services == expected + assert agent_vault.sessions.create(services=[INLINE_SERVICE_INPUT], project_id=PROJECT_ID).services == expected assert agent_vault.sessions.get(SESSION["id"]).services == expected - assert agent_vault.sessions.list().sessions[0].services == expected + assert agent_vault.sessions.list(project_id=PROJECT_ID).sessions[0].services == expected def test_sessions_from_an_infisical_version_without_inline_services_have_an_empty_list_of_them( @@ -396,10 +404,10 @@ def test_sessions_from_an_infisical_version_without_inline_services_have_an_empt json_response({"session": older}), json_response({"sessions": [older], "totalCount": 1}), ] - assert agent_vault.sessions.create(access_bundle="release").services == [] + assert agent_vault.sessions.create(access_bundle="release", project_id=PROJECT_ID).services == [] assert agent_vault.sessions.update(SESSION["id"], None).services == [] assert agent_vault.sessions.get(SESSION["id"]).services == [] - assert agent_vault.sessions.list().sessions[0].services == [] + assert agent_vault.sessions.list(project_id=PROJECT_ID).sessions[0].services == [] @pytest.mark.parametrize("status_code, payload, explanation", [ @@ -424,7 +432,7 @@ def test_sessions_create_keeps_the_servers_explanation_when_it_refuses_services( api, agent_vault, json_response, status_code, payload, explanation): api.session.post.return_value = json_response(payload, status_code) with pytest.raises(APIError) as raised: - agent_vault.sessions.create(services=[INLINE_SERVICE_INPUT]) + agent_vault.sessions.create(services=[INLINE_SERVICE_INPUT], project_id=PROJECT_ID) assert raised.value.status_code == status_code assert explanation in str(raised.value) assert api.session.post.call_count == 1 @@ -511,8 +519,8 @@ def test_api_error_is_exported_from_the_package_root(): @pytest.mark.parametrize("call", [ lambda agent_vault: agent_vault.proxies.delete(PROXY["id"]), lambda agent_vault: agent_vault.access_bundles.delete(BUNDLE["id"]), - lambda agent_vault: agent_vault.delegations.delete(MACHINE_IDENTITY_ID), - lambda agent_vault: agent_vault.delegations.remove_subjects(MACHINE_IDENTITY_ID, user_ids=[ON_BEHALF_OF["id"]]), + lambda agent_vault: agent_vault.delegations.delete(MACHINE_IDENTITY_ID, project_id=PROJECT_ID), + lambda agent_vault: agent_vault.delegations.remove_subjects(MACHINE_IDENTITY_ID, user_ids=[ON_BEHALF_OF["id"]], project_id=PROJECT_ID), ], ids=["proxies.delete", "access_bundles.delete", "delegations.delete", "delegations.remove_subjects"]) def test_deletes_are_not_retried_once_the_request_may_have_reached_the_server(api, agent_vault, call): # After a lost response, a retry would fail with a 404 for a delete that worked. @@ -520,3 +528,81 @@ def test_deletes_are_not_retried_once_the_request_may_have_reached_the_server(ap with pytest.raises(requests.exceptions.ReadTimeout): call(agent_vault) assert api.session.delete.call_count == 1 + + +LISTS = [ + ("proxies.list", lambda agent_vault, **kwargs: agent_vault.proxies.list(**kwargs), {"proxies": [], "totalCount": 0}), + ("sessions.list", lambda agent_vault, **kwargs: agent_vault.sessions.list(**kwargs), {"sessions": [], "totalCount": 0}), + ("access_bundles.list", lambda agent_vault, **kwargs: agent_vault.access_bundles.list(**kwargs), + {"accessBundles": [], "totalCount": 0}), + ("delegations.list", lambda agent_vault, **kwargs: agent_vault.delegations.list(**kwargs), + {"delegations": [], "totalCount": 0}), + ("delegations.get", lambda agent_vault, **kwargs: agent_vault.delegations.get(MACHINE_IDENTITY_ID, **kwargs), + {"delegation": DELEGATION}), + ("delegations.list_available_subjects", + lambda agent_vault, **kwargs: agent_vault.delegations.list_available_subjects(MACHINE_IDENTITY_ID, **kwargs), + {"members": [], "totalCount": 0}), +] + +CREATES = [ + ("proxies.create", lambda agent_vault, **kwargs: agent_vault.proxies.create(name="edge-proxy", **kwargs), + {"proxy": PROXY, "proxyToken": "token"}), + ("sessions.create", lambda agent_vault, **kwargs: agent_vault.sessions.create(access_bundle="release", **kwargs), + {"session": dict(SESSION, token="token")}), + ("access_bundles.create", lambda agent_vault, **kwargs: agent_vault.access_bundles.create(name="release", **kwargs), + {"accessBundle": BUNDLE}), +] + +DELEGATION_WRITES = [ + ("delegations.set", "put", + lambda agent_vault, **kwargs: agent_vault.delegations.set(MACHINE_IDENTITY_ID, "all-members", **kwargs)), + ("delegations.delete", "delete", + lambda agent_vault, **kwargs: agent_vault.delegations.delete(MACHINE_IDENTITY_ID, **kwargs)), + ("delegations.add_subjects", "post", + lambda agent_vault, **kwargs: agent_vault.delegations.add_subjects(MACHINE_IDENTITY_ID, user_ids=[ON_BEHALF_OF["id"]], **kwargs)), + ("delegations.remove_subjects", "delete", + lambda agent_vault, **kwargs: agent_vault.delegations.remove_subjects(MACHINE_IDENTITY_ID, user_ids=[ON_BEHALF_OF["id"]], **kwargs)), +] + + +@pytest.mark.parametrize("call, payload", [(call, payload) for _, call, payload in LISTS], ids=[name for name, _, _ in LISTS]) +def test_lists_send_the_project_id_in_the_query(api, agent_vault, json_response, call, payload): + api.session.get.return_value = json_response(payload) + call(agent_vault, project_id=PROJECT_ID) + assert api.session.get.call_args.kwargs["params"]["projectId"] == PROJECT_ID + + +@pytest.mark.parametrize("call, payload", [(call, payload) for _, call, payload in CREATES], ids=[name for name, _, _ in CREATES]) +def test_creates_send_the_project_id_in_the_body(api, agent_vault, json_response, call, payload): + api.session.post.return_value = json_response(payload) + call(agent_vault, project_id=PROJECT_ID) + assert api.session.post.call_args.kwargs["json"]["projectId"] == PROJECT_ID + assert api.session.post.call_args.kwargs.get("params") is None + + +@pytest.mark.parametrize("verb, call", [(verb, call) for _, verb, call in DELEGATION_WRITES], + ids=[name for name, _, _ in DELEGATION_WRITES]) +def test_delegation_writes_send_the_project_id_in_the_query_and_not_the_body(api, agent_vault, json_response, verb, call): + # A delegation is addressed by its machine identity, which can be in several projects, and the server refuses a + # projectId in the body of these routes. + getattr(api.session, verb).return_value = json_response({"delegation": DELEGATION}) + call(agent_vault, project_id=PROJECT_ID) + sent = getattr(api.session, verb).call_args.kwargs + assert sent["params"] == {"projectId": PROJECT_ID} + assert "projectId" not in (sent["json"] or {}) + + +@pytest.mark.parametrize("call", [call for _, call, _ in LISTS + CREATES] + [call for _, _, call in DELEGATION_WRITES], + ids=[name for name, _, _ in LISTS + CREATES + DELEGATION_WRITES]) +def test_the_project_id_is_required(api, agent_vault, call): + with pytest.raises(TypeError, match="project_id"): + call(agent_vault) + for verb in ("get", "post", "put", "delete"): + getattr(api.session, verb).assert_not_called() + + +def test_the_project_id_cannot_be_passed_by_position(api, agent_vault): + # Keyword-only, so a positional argument can't land in project_id by mistake. + with pytest.raises(TypeError): + agent_vault.delegations.get(MACHINE_IDENTITY_ID, PROJECT_ID) + api.session.get.assert_not_called()