diff --git a/src/keboola_agent_cli/commands/org.py b/src/keboola_agent_cli/commands/org.py index 33a97954..03e17ae2 100644 --- a/src/keboola_agent_cli/commands/org.py +++ b/src/keboola_agent_cli/commands/org.py @@ -58,10 +58,14 @@ def _format_setup_result(console: Console, data: dict) -> None: skipped = data.get("projects_skipped", []) failed = data.get("projects_failed", []) + token_expires_in = data.get("token_expires_in") mode_label = "[bold yellow]DRY RUN[/bold yellow] " if dry_run else "" + expiry_label = ( + f", token expiration: [bold]{token_expires_in}s[/bold]" if token_expires_in else "" + ) console.print( f"\n{mode_label}Organization [bold]{org_id}[/bold] on {stack_url} " - f"-- {projects_found} project(s) found\n" + f"-- {projects_found} project(s) found{expiry_label}\n" ) # Added / would-add table @@ -150,6 +154,12 @@ def org_setup( "--token-description", help="Description prefix for created Storage API tokens", ), + token_expires_in: int | None = typer.Option( + None, + "--token-expires-in", + min=1, + help="Token lifetime in seconds (e.g. 3600 for 1 hour). If not set, tokens never expire.", + ), ) -> None: """Set up all projects from a Keboola organization. @@ -176,6 +186,7 @@ def org_setup( org_id=org_id, token_description=token_description, dry_run=True, + token_expires_in=token_expires_in, ) except KeboolaApiError as exc: _handle_api_error(formatter, exc) @@ -201,6 +212,7 @@ def org_setup( org_id=org_id, token_description=token_description, dry_run=dry_run, + token_expires_in=token_expires_in, ) except KeboolaApiError as exc: _handle_api_error(formatter, exc) diff --git a/src/keboola_agent_cli/manage_client.py b/src/keboola_agent_cli/manage_client.py index 1b4a8f12..13f1a4fe 100644 --- a/src/keboola_agent_cli/manage_client.py +++ b/src/keboola_agent_cli/manage_client.py @@ -79,6 +79,7 @@ def create_project_token( description: str, can_manage_buckets: bool = True, can_read_all_file_uploads: bool = True, + expires_in: int | None = None, ) -> dict[str, Any]: """Create a new Storage API token for a project. @@ -87,6 +88,7 @@ def create_project_token( description: Token description. can_manage_buckets: Whether the token can manage buckets. can_read_all_file_uploads: Whether the token can read all file uploads. + expires_in: Token lifetime in seconds. None means the token never expires. Returns: Token dict including the 'token' field (shown only once). @@ -94,10 +96,12 @@ def create_project_token( Raises: KeboolaApiError: On API errors. """ - payload = { + payload: dict[str, Any] = { "description": description, "canManageBuckets": can_manage_buckets, "canReadAllFileUploads": can_read_all_file_uploads, } + if expires_in is not None: + payload["expiresIn"] = expires_in response = self._do_request("POST", f"/manage/projects/{project_id}/tokens", json=payload) return response.json() diff --git a/src/keboola_agent_cli/services/org_service.py b/src/keboola_agent_cli/services/org_service.py index 0569e72f..f954fb28 100644 --- a/src/keboola_agent_cli/services/org_service.py +++ b/src/keboola_agent_cli/services/org_service.py @@ -76,6 +76,7 @@ def setup_organization( org_id: int, token_description: str = DEFAULT_TOKEN_DESCRIPTION, dry_run: bool = False, + token_expires_in: int | None = None, ) -> dict[str, Any]: """Set up all projects from a Keboola organization. @@ -160,6 +161,7 @@ def setup_organization( alias=alias, token_description=token_description, owner_name=owner_name, + token_expires_in=token_expires_in, ) # Re-read to get masked token registered = self._config_store.get_project(alias) @@ -199,6 +201,7 @@ def setup_organization( "projects_skipped": skipped, "projects_failed": failed, "dry_run": dry_run, + "token_expires_in": token_expires_in, } def _setup_single_project( @@ -210,6 +213,7 @@ def _setup_single_project( alias: str, token_description: str, owner_name: str = "", + token_expires_in: int | None = None, ) -> None: """Create a token for a single project, verify it, and register it. @@ -221,6 +225,7 @@ def _setup_single_project( alias: The alias to register the project under. token_description: Description for the created token. owner_name: Email/name of the manage token owner (for unique identification). + token_expires_in: Token lifetime in seconds. None means no expiration. """ description = f"{token_description} [{owner_name}]" if owner_name else token_description @@ -237,6 +242,7 @@ def _setup_single_project( token_data = manage_client.create_project_token( project_id=project_id, description=description, + expires_in=token_expires_in, ) finally: manage_client.close() diff --git a/tests/test_manage_client.py b/tests/test_manage_client.py index 75755877..ea579a16 100644 --- a/tests/test_manage_client.py +++ b/tests/test_manage_client.py @@ -226,6 +226,50 @@ def test_custom_description_in_payload(self, httpx_mock) -> None: client.close() + def test_expires_in_included_in_payload(self, httpx_mock) -> None: + """When expires_in is set, expiresIn is sent in the request payload.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/100/tokens", + json=TOKEN_RESPONSE, + status_code=201, + ) + + client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) + client.create_project_token( + project_id=100, + description="kbagent-cli", + expires_in=3600, + ) + + request = httpx_mock.get_request() + import json + + body = json.loads(request.content) + assert body["expiresIn"] == 3600 + client.close() + + def test_expires_in_none_excluded_from_payload(self, httpx_mock) -> None: + """When expires_in is None, expiresIn key is absent from the request payload.""" + httpx_mock.add_response( + url=f"{STACK_URL}/manage/projects/100/tokens", + json=TOKEN_RESPONSE, + status_code=201, + ) + + client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN) + client.create_project_token( + project_id=100, + description="kbagent-cli", + ) + + request = httpx_mock.get_request() + import json + + body = json.loads(request.content) + assert "expiresIn" not in body + client.close() + + class TestManageClientContextManager: """Test context manager protocol.""" diff --git a/tests/test_org_service.py b/tests/test_org_service.py index 1704ffe5..8742e28f 100644 --- a/tests/test_org_service.py +++ b/tests/test_org_service.py @@ -385,6 +385,74 @@ def test_custom_token_description(self, tmp_path: Path) -> None: ) or "my-custom-prefix" in str(call_args) +class TestTokenExpiration: + """Tests for token expiration (expiresIn) parameter.""" + + def test_expires_in_passed_to_manage_client(self, tmp_path: Path) -> None: + """When token_expires_in is set, it is forwarded to create_project_token.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + + projects = [{"id": 100, "name": "Alpha"}] + + manage_mock = MagicMock() + manage_mock.list_organization_projects.return_value = projects + manage_mock.create_project_token.return_value = { + "id": "tok-1", + "token": "901-99999-generatedToken1234567890ab", + "description": "kbagent-cli", + } + + service = OrgService( + config_store=store, + manage_client_factory=lambda url, token: manage_mock, + storage_client_factory=_make_storage_client(), + ) + + service.setup_organization( + stack_url="https://connection.keboola.com", + manage_token="manage-token-123456789012345678", + org_id=42, + token_expires_in=3600, + ) + + call_kwargs = manage_mock.create_project_token.call_args.kwargs + assert call_kwargs["expires_in"] == 3600 + + def test_expires_in_none_not_in_payload(self, tmp_path: Path) -> None: + """When token_expires_in is not set, expiresIn is absent from API payload.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + + projects = [{"id": 100, "name": "Alpha"}] + + manage_mock = MagicMock() + manage_mock.list_organization_projects.return_value = projects + manage_mock.create_project_token.return_value = { + "id": "tok-1", + "token": "901-99999-generatedToken1234567890ab", + "description": "kbagent-cli", + } + + service = OrgService( + config_store=store, + manage_client_factory=lambda url, token: manage_mock, + storage_client_factory=_make_storage_client(), + ) + + service.setup_organization( + stack_url="https://connection.keboola.com", + manage_token="manage-token-123456789012345678", + org_id=42, + ) + + # Verify expires_in=None was passed (ManageClient will skip expiresIn from payload) + call_kwargs = manage_mock.create_project_token.call_args.kwargs + assert call_kwargs["expires_in"] is None + + class TestExistingProjectIdNone: """Tests for existing projects with project_id=None not polluting the set."""