bt-cli 0.4.40__tar.gz → 0.4.41__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41}/CLAUDE.md +1 -1
- {bt_cli-0.4.40 → bt_cli-0.4.41}/PKG-INFO +1 -1
- {bt_cli-0.4.40 → bt_cli-0.4.41}/pyproject.toml +1 -1
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/__init__.py +1 -1
- bt_cli-0.4.41/src/bt_cli/core/prompts.py +165 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41/src/bt_cli/data}/CLAUDE.md +1 -1
- {bt_cli-0.4.40/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/entitle/SKILL.md +49 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/client/base.py +44 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/__init__.py +2 -1
- bt_cli-0.4.41/src/bt_cli/entitle/commands/requests.py +295 -0
- bt_cli-0.4.40/src/bt_cli/core/prompts.py +0 -87
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/entitle/SKILL.md +0 -0
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/.claude}/skills/pws/SKILL.md +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/.env.example +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/.github/workflows/ci.yml +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/.github/workflows/release.yml +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/.gitignore +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/README.md +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/assets/cli-help.png +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/assets/cli-output.png +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/bt-cli.spec +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/bt_entry.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/epml-implementation-plan.md +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/scripts/bt_entry.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/scripts/sync-package-data.sh +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/cli.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/commands/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/commands/configure.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/commands/learn.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/commands/quick.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/config.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/config_file.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/csv_utils.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/errors.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/output.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/core/rest_debug.py +0 -0
- {bt_cli-0.4.40/tests/pws → bt_cli-0.4.41/src/bt_cli/data}/__init__.py +0 -0
- {bt_cli-0.4.40/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/bt/SKILL.md +0 -0
- {bt_cli-0.4.40/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/epml/SKILL.md +0 -0
- {bt_cli-0.4.40/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/epmw/SKILL.md +0 -0
- {bt_cli-0.4.40/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/pra/SKILL.md +0 -0
- {bt_cli-0.4.40/.claude → bt_cli-0.4.41/src/bt_cli/data}/skills/pws/SKILL.md +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/client/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/accounts.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/applications.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/bundles.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/integrations.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/permissions.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/policies.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/resources.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/roles.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/users.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/commands/workflows.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/bundle.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/common.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/integration.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/permission.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/policy.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/resource.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/role.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/user.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/entitle/models/workflow.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/client/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/client/base.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/audit.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/client_pkg.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/external_apis.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/hosts.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/iolog.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/license.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/quick.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_cmdgrps.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_entitlement.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_hostgrps.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_policy.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_roles.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_tests.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_tmdategrps.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_tx.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/rbp_usergrps.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/settings.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/siems.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/commands/users.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epml/models/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/client/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/client/base.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/audits.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/computers.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/events.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/groups.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/policies.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/quick.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/requests.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/roles.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/tasks.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/commands/users.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/epmw/models/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/client/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/client/base.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/import_export.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jump_clients.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jump_groups.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jump_items.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/jumpoints.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/policies.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/quick.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/teams.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/users.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/commands/vault.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/common.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/jump_client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/jump_group.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/jump_item.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/jumpoint.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/team.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/user.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pra/models/vault.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/client/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/client/base.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/client/beyondinsight.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/client/passwordsafe.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/accounts.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/assets.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/attributes.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/clouds.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/config.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/credentials.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/databases.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/directories.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/functional.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/import_export.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/platforms.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/quick.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/search.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/secrets.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/systems.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/users.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/commands/workgroups.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/config.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/models/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/models/account.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/models/asset.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/models/common.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/src/bt_cli/pws/models/system.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/conftest.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/core/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/core/test_auth.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/core/test_config.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/core/test_errors.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/core/test_rest_debug.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/entitle/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/entitle/test_client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/entitle/test_commands.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/entitle-smoke-test.sh +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epml/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epml/test_client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epml/test_commands.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epmw/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epmw/test_client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epmw/test_commands.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/epmw-quick-test-plan.md +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/fixtures/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/fixtures/responses.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/conftest.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/helpers.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_entitle_integration.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_epmw_integration.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_epmw_lifecycle.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_pra_integration.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_pra_lifecycle.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_pws_integration.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/integration/test_pws_lifecycle.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pra/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pra/test_client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pra/test_commands.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pra-smoke-test.sh +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pra-test-plan.md +0 -0
- {bt_cli-0.4.40/src/bt_cli/data → bt_cli-0.4.41/tests/pws}/__init__.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pws/test_client.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pws/test_commands.py +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pws-quick-test-plan.md +0 -0
- {bt_cli-0.4.40 → bt_cli-0.4.41}/tests/pws-smoke-test.sh +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Reusable interactive prompts for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Optional, TypeVar
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
D = TypeVar("D")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def prompt_if_missing(
|
|
15
|
+
value: Optional[T],
|
|
16
|
+
prompt_text: str,
|
|
17
|
+
value_type: type = str,
|
|
18
|
+
) -> T:
|
|
19
|
+
"""Prompt for a value if it's missing.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
value: Current value (may be None)
|
|
23
|
+
prompt_text: Text to show in prompt
|
|
24
|
+
value_type: Type for typer.prompt (str, int, float)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The value (either original or prompted)
|
|
28
|
+
"""
|
|
29
|
+
if value is None or value == "":
|
|
30
|
+
return typer.prompt(prompt_text, type=value_type)
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def prompt_from_list(
|
|
35
|
+
items: list[dict[str, Any]],
|
|
36
|
+
prompt_text: str,
|
|
37
|
+
id_key: str,
|
|
38
|
+
name_key: str,
|
|
39
|
+
title: str,
|
|
40
|
+
value_type: type = int,
|
|
41
|
+
) -> Any:
|
|
42
|
+
"""Show a list of items and prompt for selection.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
items: List of dicts to display
|
|
46
|
+
prompt_text: Prompt text (e.g., "Workgroup ID")
|
|
47
|
+
id_key: Key for the ID field in each dict
|
|
48
|
+
name_key: Key for the display name field
|
|
49
|
+
title: Title to show above list
|
|
50
|
+
value_type: Type of the ID (int or str)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The selected ID
|
|
54
|
+
"""
|
|
55
|
+
console.print(f"\n[bold]{title}:[/bold]")
|
|
56
|
+
for item in items:
|
|
57
|
+
item_id = item.get(id_key, "")
|
|
58
|
+
item_name = item.get(name_key, "Unknown")
|
|
59
|
+
console.print(f" {item_id}: {item_name}")
|
|
60
|
+
return typer.prompt(prompt_text, type=value_type)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def prompt_choice(
|
|
64
|
+
prompt_text: str,
|
|
65
|
+
choices: list[tuple[str, str]],
|
|
66
|
+
default: Optional[str] = None,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Prompt for a choice from a list of options.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
prompt_text: Text to show
|
|
72
|
+
choices: List of (value, description) tuples
|
|
73
|
+
default: Default value
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The selected value
|
|
77
|
+
"""
|
|
78
|
+
console.print(f"\n[bold]{prompt_text}:[/bold]")
|
|
79
|
+
for value, desc in choices:
|
|
80
|
+
marker = " (default)" if value == default else ""
|
|
81
|
+
console.print(f" {value}: {desc}{marker}")
|
|
82
|
+
|
|
83
|
+
result = typer.prompt("Choice", default=default or choices[0][0])
|
|
84
|
+
valid_values = [v for v, _ in choices]
|
|
85
|
+
while result not in valid_values:
|
|
86
|
+
console.print(f"[red]Invalid choice. Options: {', '.join(valid_values)}[/red]")
|
|
87
|
+
result = typer.prompt("Choice", default=default or choices[0][0])
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def prompt_filtered_pick(
|
|
92
|
+
items: list[D],
|
|
93
|
+
label_fn: Callable[[D], str],
|
|
94
|
+
*,
|
|
95
|
+
title: str = "Select",
|
|
96
|
+
page_size: int = 20,
|
|
97
|
+
) -> D:
|
|
98
|
+
"""Paginated filter-then-pick selector.
|
|
99
|
+
|
|
100
|
+
User actions at the prompt:
|
|
101
|
+
- a number (1..N) picks the item shown on the current page
|
|
102
|
+
- any other text filters the list by case-insensitive substring of the label
|
|
103
|
+
- 'n' / 'p' moves to next / previous page
|
|
104
|
+
- empty input cancels (raises typer.Abort)
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
items: Source list to choose from
|
|
108
|
+
label_fn: Maps an item to the label shown in the list (also used for filtering)
|
|
109
|
+
title: Heading shown above the list
|
|
110
|
+
page_size: Items per page
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The chosen item from `items`.
|
|
114
|
+
"""
|
|
115
|
+
if not items:
|
|
116
|
+
console.print(f"[yellow]No {title.lower()} available[/yellow]")
|
|
117
|
+
raise typer.Abort()
|
|
118
|
+
|
|
119
|
+
visible = list(items)
|
|
120
|
+
page = 0
|
|
121
|
+
while True:
|
|
122
|
+
total_pages = max(1, (len(visible) + page_size - 1) // page_size)
|
|
123
|
+
page = max(0, min(page, total_pages - 1))
|
|
124
|
+
start = page * page_size
|
|
125
|
+
chunk = visible[start : start + page_size]
|
|
126
|
+
|
|
127
|
+
match_count = len(visible)
|
|
128
|
+
suffix = f" — page {page + 1}/{total_pages}" if total_pages > 1 else ""
|
|
129
|
+
console.print(f"\n[bold]{title}[/bold] ({match_count} match{'es' if match_count != 1 else ''}{suffix}):")
|
|
130
|
+
for i, item in enumerate(chunk, start=1):
|
|
131
|
+
console.print(f" {i}. {label_fn(item)}")
|
|
132
|
+
console.print(
|
|
133
|
+
"[dim]Type a number to pick, text to filter, n/p for next/prev page, blank to cancel[/dim]"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
ans = typer.prompt(">", default="", show_default=False).strip()
|
|
137
|
+
if ans == "":
|
|
138
|
+
raise typer.Abort()
|
|
139
|
+
low = ans.lower()
|
|
140
|
+
if low == "n":
|
|
141
|
+
if page + 1 < total_pages:
|
|
142
|
+
page += 1
|
|
143
|
+
else:
|
|
144
|
+
console.print("[yellow]Already on last page[/yellow]")
|
|
145
|
+
continue
|
|
146
|
+
if low == "p":
|
|
147
|
+
if page > 0:
|
|
148
|
+
page -= 1
|
|
149
|
+
else:
|
|
150
|
+
console.print("[yellow]Already on first page[/yellow]")
|
|
151
|
+
continue
|
|
152
|
+
if ans.isdigit():
|
|
153
|
+
idx = int(ans) - 1
|
|
154
|
+
if 0 <= idx < len(chunk):
|
|
155
|
+
return chunk[idx]
|
|
156
|
+
console.print(f"[red]Invalid number — must be 1..{len(chunk)}[/red]")
|
|
157
|
+
continue
|
|
158
|
+
# Filter against the full source list, not the previously filtered view
|
|
159
|
+
needle = low
|
|
160
|
+
filtered = [it for it in items if needle in label_fn(it).lower()]
|
|
161
|
+
if not filtered:
|
|
162
|
+
console.print("[yellow]No matches — keeping previous list[/yellow]")
|
|
163
|
+
continue
|
|
164
|
+
visible = filtered
|
|
165
|
+
page = 0
|
|
@@ -115,6 +115,55 @@ bt entitle permissions revoke <permission_id>
|
|
|
115
115
|
bt entitle accounts list --integration <integration_id>
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
## Access Requests
|
|
119
|
+
|
|
120
|
+
Submit a JIT access request for a bundle or a single role. Hybrid UX — pass flags
|
|
121
|
+
for non-interactive use, or omit them to get a filter-then-pick menu at every step.
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Fully interactive (asks: bundle/role -> target -> duration -> justification)
|
|
125
|
+
bt entitle requests create
|
|
126
|
+
|
|
127
|
+
# Non-interactive — bundle target
|
|
128
|
+
bt entitle requests create --bundle <bundle_id> --duration 3600 -j "On-call investigation"
|
|
129
|
+
|
|
130
|
+
# Non-interactive — role target
|
|
131
|
+
bt entitle requests create --role <role_id> -d 1800 -j "Reviewing prod logs"
|
|
132
|
+
|
|
133
|
+
# Look up a submitted request by id
|
|
134
|
+
bt entitle requests get <request_id>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Target shape (POST /accessRequests):**
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"duration": 3600,
|
|
141
|
+
"justification": "...",
|
|
142
|
+
"target": {
|
|
143
|
+
"type": "bundle", // or "role"
|
|
144
|
+
"bundle": {"id": "<uuid>"} // or "role": {"id": "<uuid>"}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Duration menu** is curated 30-minute-aligned blocks (30m, 1h, 1h30m, 2h, 3h, 4h,
|
|
150
|
+
8h, 12h, 1d, 2d, 3d, 7d). When the target is a bundle and that bundle has
|
|
151
|
+
`allowedDurations` set, the menu restricts to those values instead — bundles
|
|
152
|
+
enforce server-side.
|
|
153
|
+
|
|
154
|
+
**Picker UX:** at any selection prompt, type a number to pick, any text to filter
|
|
155
|
+
the list by substring, `n` / `p` to page, or blank to cancel.
|
|
156
|
+
|
|
157
|
+
**Gotchas:**
|
|
158
|
+
- The Entitle public API does **not** expose a list endpoint for access requests.
|
|
159
|
+
`bt entitle requests list` does not exist. Save the id printed by `requests
|
|
160
|
+
create` (or use the Entitle UI) to follow up on a request.
|
|
161
|
+
- A bogus or policy-blocked role returns the same 404 with message `Role with id
|
|
162
|
+
... does not exist or unattainable.` — "unattainable" means the requester's
|
|
163
|
+
policy disallows it, not that the role is missing.
|
|
164
|
+
- The token used must have a user identity (personal API token). Pure read-only
|
|
165
|
+
org keys return `request.unauthenticated` on POST.
|
|
166
|
+
|
|
118
167
|
## Roles & Policies
|
|
119
168
|
|
|
120
169
|
```bash
|
|
@@ -433,6 +433,50 @@ class EntitleClient:
|
|
|
433
433
|
|
|
434
434
|
|
|
435
435
|
# =========================================================================
|
|
436
|
+
# Access Requests
|
|
437
|
+
# =========================================================================
|
|
438
|
+
#
|
|
439
|
+
# Public API exposes:
|
|
440
|
+
# POST /accessRequests -- create
|
|
441
|
+
# GET /accessRequests/{id} -- show
|
|
442
|
+
# There is no list endpoint (verified: GET /accessRequests is 404). Callers
|
|
443
|
+
# who need to revisit a request must keep its id from the create response.
|
|
444
|
+
|
|
445
|
+
def create_access_request(
|
|
446
|
+
self,
|
|
447
|
+
target_type: str,
|
|
448
|
+
target_id: str,
|
|
449
|
+
duration: int,
|
|
450
|
+
justification: str,
|
|
451
|
+
) -> dict[str, Any]:
|
|
452
|
+
"""Create an access request for a bundle or role.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
target_type: "bundle" or "role"
|
|
456
|
+
target_id: UUID of the bundle or role being requested
|
|
457
|
+
duration: Requested access duration in seconds (>= 1)
|
|
458
|
+
justification: Business justification (1..2048 chars)
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Created access request payload (includes the request id).
|
|
462
|
+
"""
|
|
463
|
+
if target_type not in ("bundle", "role"):
|
|
464
|
+
raise ValueError(f"target_type must be 'bundle' or 'role', got {target_type!r}")
|
|
465
|
+
payload: dict[str, Any] = {
|
|
466
|
+
"duration": duration,
|
|
467
|
+
"justification": justification,
|
|
468
|
+
"target": {
|
|
469
|
+
"type": target_type,
|
|
470
|
+
target_type: {"id": target_id},
|
|
471
|
+
},
|
|
472
|
+
}
|
|
473
|
+
return self.post("/accessRequests", json=payload)
|
|
474
|
+
|
|
475
|
+
def get_access_request(self, request_id: str) -> dict[str, Any]:
|
|
476
|
+
"""Get an access request by ID."""
|
|
477
|
+
return self.get(f"/accessRequests/{request_id}")
|
|
478
|
+
|
|
479
|
+
|
|
436
480
|
def get_client() -> EntitleClient:
|
|
437
481
|
"""Create a configured Entitle client.
|
|
438
482
|
|
|
@@ -10,7 +10,7 @@ app = typer.Typer(
|
|
|
10
10
|
|
|
11
11
|
# Import and register command groups
|
|
12
12
|
from . import auth, integrations, resources, roles, bundles
|
|
13
|
-
from . import workflows, users, permissions, policies, accounts
|
|
13
|
+
from . import workflows, users, permissions, policies, accounts, requests
|
|
14
14
|
|
|
15
15
|
app.add_typer(auth.app, name="auth", help="Authentication commands")
|
|
16
16
|
app.add_typer(integrations.app, name="integrations", help="Manage integrations")
|
|
@@ -22,3 +22,4 @@ app.add_typer(users.app, name="users", help="Manage users")
|
|
|
22
22
|
app.add_typer(permissions.app, name="permissions", help="Manage permissions")
|
|
23
23
|
app.add_typer(policies.app, name="policies", help="Manage policies")
|
|
24
24
|
app.add_typer(accounts.app, name="accounts", help="Manage accounts")
|
|
25
|
+
app.add_typer(requests.app, name="requests", help="Manage access requests")
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Access request commands for Entitle.
|
|
2
|
+
|
|
3
|
+
Hybrid UX: pass --bundle or --role + --duration + --justification for a fully
|
|
4
|
+
non-interactive run, or omit any of those to drop into a filter-then-pick menu.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from ..client.base import EntitleClient, get_client
|
|
13
|
+
from ...core.output import console, print_json, print_success, print_api_error
|
|
14
|
+
from ...core.prompts import prompt_choice, prompt_filtered_pick
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True, help="Manage access requests")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Curated 30-min-aligned duration menu (seconds)
|
|
20
|
+
DURATION_BLOCKS_SEC: list[int] = [
|
|
21
|
+
1800, # 30m
|
|
22
|
+
3600, # 1h
|
|
23
|
+
5400, # 1h30m
|
|
24
|
+
7200, # 2h
|
|
25
|
+
10800, # 3h
|
|
26
|
+
14400, # 4h
|
|
27
|
+
28800, # 8h
|
|
28
|
+
43200, # 12h
|
|
29
|
+
86400, # 1d
|
|
30
|
+
172800, # 2d
|
|
31
|
+
259200, # 3d
|
|
32
|
+
604800, # 7d
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fmt_duration(secs: int) -> str:
|
|
37
|
+
"""Render a seconds value as a compact human label (e.g. 1h30m, 2d)."""
|
|
38
|
+
if secs <= 0:
|
|
39
|
+
return f"{secs}s"
|
|
40
|
+
days, rem = divmod(secs, 86400)
|
|
41
|
+
hours, rem = divmod(rem, 3600)
|
|
42
|
+
mins = rem // 60
|
|
43
|
+
parts: list[str] = []
|
|
44
|
+
if days:
|
|
45
|
+
parts.append(f"{days}d")
|
|
46
|
+
if hours:
|
|
47
|
+
parts.append(f"{hours}h")
|
|
48
|
+
if mins:
|
|
49
|
+
parts.append(f"{mins}m")
|
|
50
|
+
return "".join(parts) or f"{secs}s"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pick_target_interactive(client: EntitleClient) -> tuple[str, str, str]:
|
|
54
|
+
"""Walk the user through choosing a bundle or a role.
|
|
55
|
+
|
|
56
|
+
Returns (target_type, target_id, human_label).
|
|
57
|
+
"""
|
|
58
|
+
target_type = prompt_choice(
|
|
59
|
+
"Target type",
|
|
60
|
+
[
|
|
61
|
+
("role", "A single role on a resource (drill down: integration → resource → role)"),
|
|
62
|
+
("bundle", "An access bundle (a curated group of roles)"),
|
|
63
|
+
],
|
|
64
|
+
default="role",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if target_type == "bundle":
|
|
68
|
+
bundles = client.list_bundles()
|
|
69
|
+
chosen = prompt_filtered_pick(
|
|
70
|
+
bundles,
|
|
71
|
+
lambda b: f"{b.get('name', '?')} [dim]{b.get('id', '')}[/dim]",
|
|
72
|
+
title="Pick a bundle",
|
|
73
|
+
)
|
|
74
|
+
return "bundle", chosen["id"], chosen.get("name") or chosen["id"]
|
|
75
|
+
|
|
76
|
+
integrations = client.list_integrations()
|
|
77
|
+
integration = prompt_filtered_pick(
|
|
78
|
+
integrations,
|
|
79
|
+
lambda x: f"{x.get('name', '?')} [dim]({(x.get('application') or {}).get('name', '-')})[/dim]",
|
|
80
|
+
title="Pick an integration",
|
|
81
|
+
)
|
|
82
|
+
resources = client.list_resources(integration_id=integration["id"])
|
|
83
|
+
resource = prompt_filtered_pick(
|
|
84
|
+
resources,
|
|
85
|
+
lambda x: x.get("name", "?"),
|
|
86
|
+
title="Pick a resource",
|
|
87
|
+
)
|
|
88
|
+
roles = client.list_roles(resource_id=resource["id"])
|
|
89
|
+
role = prompt_filtered_pick(
|
|
90
|
+
roles,
|
|
91
|
+
lambda x: x.get("name", "?"),
|
|
92
|
+
title="Pick a role",
|
|
93
|
+
)
|
|
94
|
+
label = f"{integration.get('name', '?')} / {resource.get('name', '?')} / {role.get('name', '?')}"
|
|
95
|
+
return "role", role["id"], label
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _pick_duration_interactive(
|
|
99
|
+
client: EntitleClient, target_type: str, target_id: str
|
|
100
|
+
) -> int:
|
|
101
|
+
"""Show a duration menu. For bundles with allowedDurations, restrict to those."""
|
|
102
|
+
options: list[int] = []
|
|
103
|
+
|
|
104
|
+
if target_type == "bundle":
|
|
105
|
+
try:
|
|
106
|
+
response = client.get_bundle(target_id)
|
|
107
|
+
bundle = response.get("result", response)
|
|
108
|
+
allowed = bundle.get("allowedDurations") or []
|
|
109
|
+
options = [int(d) for d in allowed if int(d) > 0]
|
|
110
|
+
except Exception:
|
|
111
|
+
# If the lookup fails for any reason, fall back to curated blocks
|
|
112
|
+
options = []
|
|
113
|
+
|
|
114
|
+
if not options:
|
|
115
|
+
options = list(DURATION_BLOCKS_SEC)
|
|
116
|
+
|
|
117
|
+
options.sort()
|
|
118
|
+
chosen = prompt_filtered_pick(
|
|
119
|
+
options,
|
|
120
|
+
lambda secs: f"{_fmt_duration(secs)} [dim]({secs}s)[/dim]",
|
|
121
|
+
title="Pick a duration",
|
|
122
|
+
)
|
|
123
|
+
return chosen
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _prompt_justification() -> str:
|
|
127
|
+
while True:
|
|
128
|
+
text = typer.prompt("Justification (business reason)").strip()
|
|
129
|
+
if 1 <= len(text) <= 2048:
|
|
130
|
+
return text
|
|
131
|
+
console.print("[red]Justification must be 1..2048 characters[/red]")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command("create")
|
|
135
|
+
def create_access_request(
|
|
136
|
+
bundle: Optional[str] = typer.Option(None, "--bundle", "-b", help="Bundle ID to request"),
|
|
137
|
+
role: Optional[str] = typer.Option(None, "--role", "-r", help="Role ID to request"),
|
|
138
|
+
duration: Optional[int] = typer.Option(
|
|
139
|
+
None,
|
|
140
|
+
"--duration",
|
|
141
|
+
"-d",
|
|
142
|
+
help="Access duration in seconds (interactive picker uses 30-min blocks)",
|
|
143
|
+
),
|
|
144
|
+
justification: Optional[str] = typer.Option(
|
|
145
|
+
None, "--justification", "-j", help="Business justification (1..2048 chars)"
|
|
146
|
+
),
|
|
147
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the pre-submit confirmation"),
|
|
148
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Create a new access request.
|
|
151
|
+
|
|
152
|
+
Pass --bundle OR --role plus --duration and --justification for non-interactive
|
|
153
|
+
use. Omit any of them to drop into a filter-then-pick menu.
|
|
154
|
+
"""
|
|
155
|
+
if bundle and role:
|
|
156
|
+
console.print("[red]Pass either --bundle or --role, not both[/red]")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
with get_client() as client:
|
|
161
|
+
# Resolve target
|
|
162
|
+
if bundle:
|
|
163
|
+
target_type, target_id, target_label = "bundle", bundle, bundle
|
|
164
|
+
elif role:
|
|
165
|
+
target_type, target_id, target_label = "role", role, role
|
|
166
|
+
else:
|
|
167
|
+
target_type, target_id, target_label = _pick_target_interactive(client)
|
|
168
|
+
|
|
169
|
+
# Resolve duration
|
|
170
|
+
if duration is None:
|
|
171
|
+
duration = _pick_duration_interactive(client, target_type, target_id)
|
|
172
|
+
elif duration < 1:
|
|
173
|
+
console.print("[red]--duration must be >= 1 second[/red]")
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
|
|
176
|
+
# Resolve justification
|
|
177
|
+
if justification is None:
|
|
178
|
+
justification = _prompt_justification()
|
|
179
|
+
else:
|
|
180
|
+
if not (1 <= len(justification) <= 2048):
|
|
181
|
+
console.print("[red]--justification must be 1..2048 characters[/red]")
|
|
182
|
+
raise typer.Exit(1)
|
|
183
|
+
|
|
184
|
+
# Pre-submit summary
|
|
185
|
+
console.print("\n[bold]Access request summary[/bold]")
|
|
186
|
+
console.print(f" Target type : {target_type}")
|
|
187
|
+
console.print(f" Target : {target_label}")
|
|
188
|
+
console.print(f" Target ID : {target_id}")
|
|
189
|
+
console.print(f" Duration : {_fmt_duration(duration)} ({duration}s)")
|
|
190
|
+
console.print(f" Justification : {justification}")
|
|
191
|
+
|
|
192
|
+
if not yes:
|
|
193
|
+
typer.confirm("\nSubmit this request?", default=True, abort=True)
|
|
194
|
+
|
|
195
|
+
result = client.create_access_request(
|
|
196
|
+
target_type=target_type,
|
|
197
|
+
target_id=target_id,
|
|
198
|
+
duration=duration,
|
|
199
|
+
justification=justification,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
request_id = (
|
|
203
|
+
(result.get("result") or {}).get("id")
|
|
204
|
+
if isinstance(result, dict) and "result" in result
|
|
205
|
+
else (result.get("id") if isinstance(result, dict) else None)
|
|
206
|
+
)
|
|
207
|
+
print_success("Access request submitted")
|
|
208
|
+
if request_id:
|
|
209
|
+
console.print(f"[dim]Follow up with:[/dim] bt entitle requests get {request_id}")
|
|
210
|
+
|
|
211
|
+
if output == "json":
|
|
212
|
+
print_json(result)
|
|
213
|
+
else:
|
|
214
|
+
print_json(result) # response shape isn't documented; show full payload
|
|
215
|
+
except typer.Abort:
|
|
216
|
+
raise
|
|
217
|
+
except httpx.HTTPStatusError as e:
|
|
218
|
+
print_api_error(e, "create access request")
|
|
219
|
+
raise typer.Exit(1)
|
|
220
|
+
except httpx.RequestError as e:
|
|
221
|
+
print_api_error(e, "create access request")
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print_api_error(e, "create access request")
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command("get")
|
|
229
|
+
def get_access_request(
|
|
230
|
+
request_id: str = typer.Argument(..., help="Access request ID"),
|
|
231
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table, json"),
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Get an access request by ID."""
|
|
234
|
+
try:
|
|
235
|
+
with get_client() as client:
|
|
236
|
+
response = client.get_access_request(request_id)
|
|
237
|
+
|
|
238
|
+
data: Any = response.get("result", response) if isinstance(response, dict) else response
|
|
239
|
+
|
|
240
|
+
if output == "json":
|
|
241
|
+
print_json(response)
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
from rich.panel import Panel
|
|
245
|
+
|
|
246
|
+
if not isinstance(data, dict):
|
|
247
|
+
print_json(response)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
# Build a friendly summary line for each target. Role targets join in via
|
|
251
|
+
# `roles[]` (with nested resource + integration); bundle targets via
|
|
252
|
+
# `bundles[]`. Fall back to whatever's inside `targets[]` itself.
|
|
253
|
+
target_lines: list[str] = []
|
|
254
|
+
roles_by_id = {r.get("id"): r for r in (data.get("roles") or []) if r.get("id")}
|
|
255
|
+
bundles_by_id = {b.get("id"): b for b in (data.get("bundles") or []) if b.get("id")}
|
|
256
|
+
for t in (data.get("targets") or []):
|
|
257
|
+
t_type = t.get("type") or "?"
|
|
258
|
+
inner = t.get(t_type) or {}
|
|
259
|
+
inner_id = inner.get("id")
|
|
260
|
+
if t_type == "role" and inner_id in roles_by_id:
|
|
261
|
+
role = roles_by_id[inner_id]
|
|
262
|
+
resource = role.get("resource") or {}
|
|
263
|
+
integration = resource.get("integration") or {}
|
|
264
|
+
target_lines.append(
|
|
265
|
+
f"role: {role.get('name', '?')} on {resource.get('name', '?')} ({integration.get('name', '?')})"
|
|
266
|
+
)
|
|
267
|
+
elif t_type == "bundle" and inner_id in bundles_by_id:
|
|
268
|
+
target_lines.append(f"bundle: {bundles_by_id[inner_id].get('name', '?')}")
|
|
269
|
+
else:
|
|
270
|
+
target_lines.append(f"{t_type}: {inner.get('name') or inner_id or '?'}")
|
|
271
|
+
targets_str = "\n ".join(target_lines) if target_lines else "-"
|
|
272
|
+
|
|
273
|
+
user = data.get("user") or data.get("behalfOf") or {}
|
|
274
|
+
requester = user.get("email") or user.get("id") or "-"
|
|
275
|
+
number = data.get("number")
|
|
276
|
+
id_line = f"{data.get('id', '-')}" + (f" [dim](#{number})[/dim]" if number else "")
|
|
277
|
+
|
|
278
|
+
body = (
|
|
279
|
+
f"[dim]ID:[/dim] {id_line}\n"
|
|
280
|
+
f"[dim]Status:[/dim] {data.get('status', '-')}\n"
|
|
281
|
+
f"[dim]Requester:[/dim] {requester}\n"
|
|
282
|
+
f"[dim]Duration:[/dim] {_fmt_duration(int(data.get('duration', 0))) if data.get('duration') else '-'}\n"
|
|
283
|
+
f"[dim]Targets:[/dim] {targets_str}\n"
|
|
284
|
+
f"[dim]Justification:[/dim] {data.get('justification', '-')}"
|
|
285
|
+
)
|
|
286
|
+
console.print(Panel(body, title="Access Request"))
|
|
287
|
+
except httpx.HTTPStatusError as e:
|
|
288
|
+
print_api_error(e, "get access request")
|
|
289
|
+
raise typer.Exit(1)
|
|
290
|
+
except httpx.RequestError as e:
|
|
291
|
+
print_api_error(e, "get access request")
|
|
292
|
+
raise typer.Exit(1)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
print_api_error(e, "get access request")
|
|
295
|
+
raise typer.Exit(1)
|