From 3658d46f1fc396f19053942a88ff31a68d826c41 Mon Sep 17 00:00:00 2001 From: John Ajera <37360952+jajera@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:19:12 +0000 Subject: [PATCH] feat: add bitwarden set secret adds support to set and list bitwarden secrets --- docs/bwsm_secret.md | 637 ++++++++++- scripts/bwsm_secret.py | 2041 ++++++++++++++++++++++++++++++++--- scripts/bwsm_secret.sh | 8 +- scripts/test_bwsm_secret.sh | 400 +++++++ 4 files changed, 2913 insertions(+), 173 deletions(-) create mode 100755 scripts/test_bwsm_secret.sh diff --git a/docs/bwsm_secret.md b/docs/bwsm_secret.md index 8a1dba2..280d6cc 100644 --- a/docs/bwsm_secret.md +++ b/docs/bwsm_secret.md @@ -11,10 +11,10 @@ This script manages secrets in Bitwarden Secrets Manager using the Bitwarden SDK ### Subcommands - `get` - Get a secret value (default if no subcommand provided) -- `create` - Create a new secret (coming soon) -- `update` - Update an existing secret (coming soon) -- `delete` - Delete secret(s) (coming soon) -- `list` - List all secrets (coming soon) +- `create` - Create a new secret +- `update` - Update an existing secret +- `delete` - Delete secret(s) +- `list` - List all secrets **Note**: For backward compatibility, if no subcommand is provided, `get` is assumed. @@ -34,33 +34,33 @@ bash <(curl -s https://raw.githubusercontent.com/jdevto/cli-tools/main/scripts/b The `get` subcommand retrieves a secret value from Bitwarden Secrets Manager. -### Configuration Sources +### Get Configuration Sources The script supports multiple configuration sources (checked in priority order): -#### 1. Command-Line Arguments (Highest Priority) +#### 1. Get: Command-Line Arguments (Highest Priority) ```bash ./bwsm_secret.sh get --secret-id --access-token "$BWS_ACCESS_TOKEN" ``` -#### 2. Environment Variables +#### 2. Get: Environment Variables ```bash export BWS_ACCESS_TOKEN="..." export BWS_SECRET_ID="..." -export BWS_ORG_ID="..." # Optional, for organization-level secrets (or use BW_ORGANIZATION_ID) +export BWS_ORG_ID="..." # Optional, for organization-level secrets ./bwsm_secret.sh get ``` -**Note**: The script supports both `BWS_ORG_ID` (standard) and `BW_ORGANIZATION_ID` (legacy) for organization ID. +**Note**: The script uses `BWS_ORG_ID` environment variable for organization ID. ### Options (Get Subcommand) - `--secret-id `: Secret ID (UUID format required) to fetch - `--secret-name `: Secret name/key to fetch (requires `--org-id`) - `--access-token `: Bitwarden Secrets Manager access token (prefer env var `BWS_ACCESS_TOKEN`) -- `--org-id `: Organization ID (UUID) - required when using `--secret-name`, optional otherwise (prefer env var `BWS_ORG_ID` or `BW_ORGANIZATION_ID`) +- `--org-id `: Organization ID (UUID) - required when using `--secret-name`, optional otherwise (prefer env var `BWS_ORG_ID`) - `--json`: Print JSON output (includes secret_id/secret_name, source, value) - `--debug`: Print debug logs to stderr @@ -97,7 +97,7 @@ export BWS_ORG_ID="123e4567-e89b-12d3-a456-426614174000" # Optional **Note**: Organization ID is optional and is only used for reference/debugging. The organization is scoped via the access token. -#### JSON Output +#### Get: JSON Output ```bash ./bwsm_secret.sh get --secret-id --access-token "$BWS_ACCESS_TOKEN" --json @@ -115,6 +115,616 @@ Output: ./bwsm_secret.sh get --secret-id --access-token "$BWS_ACCESS_TOKEN" --debug ``` +## Create Subcommand + +The `create` subcommand creates a new secret in Bitwarden Secrets Manager. + +### Create Configuration Sources + +The script supports multiple configuration sources (checked in priority order): + +#### 1. Create: Command-Line Arguments (Highest Priority) + +```bash +./bwsm_secret.sh create --key "my-secret" --value "secret-value" --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### 2. Create: Environment Variables + +```bash +export BWS_ACCESS_TOKEN="..." +export BWS_ORG_ID="..." +./bwsm_secret.sh create --key "my-secret" --value "secret-value" --project-ids "$PROJECT_ID" +``` + +**Note**: `--project-ids` is always required, even when using environment variables for other parameters. + +**Note**: The script uses `BWS_ORG_ID` environment variable for organization ID. + +### Options (Create Subcommand) + +- `--key `: Secret key/name (required) +- `--value `: Secret value (optional, will read from stdin if not provided) +- `--org-id `: Organization ID (UUID format, required) (prefer env var `BWS_ORG_ID`) +- `--project-ids `: Comma-separated list of project IDs (UUIDs, **required**). Secrets must be created within a project, not at the organization level. +- `--note `: Note/description for the secret (optional) +- `--access-token `: Bitwarden Secrets Manager access token (prefer env var `BWS_ACCESS_TOKEN`) +- `--allow-duplicate`: Allow creating a secret even if one with the same key already exists in the project(s). By default, duplicate keys are not allowed. +- `--json`: Print JSON output (includes secret_id, key, org_id, note, project_ids) instead of just secret ID +- `--debug`: Print debug logs to stderr + +### Example Usage (Create) + +**Important**: Secrets must be created within a project, not at the organization level. The `--project-ids` parameter is **required** for all create operations. + +#### Basic Create + +```bash +./bwsm_secret.sh create --key "my-secret" --value "secret-value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Using Stdin Value + +```bash +echo "secret-value" | ./bwsm_secret.sh create --key "my-secret" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### With Note + +```bash +./bwsm_secret.sh create --key "api-key" --value "key-value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --note "API key for production service" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### With Multiple Project IDs + +```bash +./bwsm_secret.sh create --key "db-password" --value "password123" --org-id "$BWS_ORG_ID" --project-ids "proj-id-1,proj-id-2" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Allow Duplicate Keys + +By default, the script prevents creating secrets with duplicate keys in the same project(s). Use `--allow-duplicate` to override: + +```bash +./bwsm_secret.sh create --key "my-secret" --value "new-value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --allow-duplicate --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Create: JSON Output + +```bash +./bwsm_secret.sh create --key "my-secret" --value "value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --json --access-token "$BWS_ACCESS_TOKEN" +``` + +Output: + +```json +{"secret_id":"123e4567-e89b-12d3-a456-426614174000","key":"my-secret","org_id":"...","note":"Optional note","project_ids":["..."]} +``` + +#### Create: Using Environment Variables + +```bash +export BWS_ACCESS_TOKEN="your-access-token" +export BWS_ORG_ID="123e4567-e89b-12d3-a456-426614174000" +echo "secret-value" | ./bwsm_secret.sh create --key "my-secret" --project-ids "$PROJECT_ID" +``` + +**Note**: `--project-ids` is always required, even when using environment variables for other parameters. + +### Output (Create) + +By default, the `create` subcommand prints **only the secret ID** to stdout (suitable for piping): + +```bash +SECRET_ID=$(./bwsm_secret.sh create --key "my-secret" --value "value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN") +echo "Created secret: $SECRET_ID" +``` + +Use `--json` for structured output with metadata. + +### Error Handling (Create) + +#### Missing Required Parameters + +```bash +$ ./bwsm_secret.sh create --key "my-secret" +Config error: missing organization ID. +Provide organization ID via one of: + - CLI: --org-id + - Env: BWS_ORG_ID +``` + +#### Missing Project IDs + +```bash +$ ./bwsm_secret.sh create --key "my-secret" --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" +Config error: missing project IDs. +Secrets must be created within a project, not at the organization level. +Provide at least one project ID via: + - CLI: --project-ids [,uuid2,...] +Note: Multiple project IDs can be provided as a comma-separated list. +``` + +#### Missing Value + +```bash +$ ./bwsm_secret.sh create --key "my-secret" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" +Error: Secret value is required. Provide via --value or pipe to stdin. +``` + +#### Invalid UUID Format + +```bash +$ ./bwsm_secret.sh create --key "my-secret" --value "value" --org-id "invalid-uuid" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" +Error: Organization ID must be a valid UUID format. Got: 'invalid-uuid'. +``` + +#### Duplicate Secret Key + +By default, the script prevents creating secrets with duplicate keys in the same project(s): + +```bash +$ ./bwsm_secret.sh create --key "my-secret" --value "value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" +Error: A secret with key 'my-secret' already exists in one or more of the specified projects (secret ID: 123e4567-e89b-12d3-a456-426614174000). Use --allow-duplicate to create anyway. +``` + +To allow duplicates, use the `--allow-duplicate` flag: + +```bash +./bwsm_secret.sh create --key "my-secret" --value "value" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --allow-duplicate --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Resource Not Found (404) + +If you get a 404 error, it typically means: + +- The organization ID or project ID is invalid or doesn't exist +- The access token doesn't have permission for the organization/project +- **Most commonly**: You're trying to create a secret at the organization level instead of within a project + +```bash +$ ./bwsm_secret.sh create --key "my-secret" --value "value" --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" +Error: Resource not found. [error details] +This may indicate: + - Invalid organization ID or project ID + - Organization or project doesn't exist + - Access token doesn't have permission + - Note: Secrets must be created within a project, not at the organization level +``` + +## Delete Subcommand + +The `delete` subcommand removes secrets from Bitwarden Secrets Manager. + +### Delete Configuration Sources + +The script supports multiple configuration sources (checked in priority order): + +#### 1. Delete: Command-Line Arguments (Highest Priority) + +```bash +./bwsm_secret.sh delete --secret-id --access-token "$BWS_ACCESS_TOKEN" --force +``` + +#### 2. Delete: Environment Variables + +```bash +export BWS_ACCESS_TOKEN="..." +export BWS_SECRET_ID="..." +./bwsm_secret.sh delete --force +``` + +**Note**: The script uses `BWS_ORG_ID` environment variable for organization ID when using `--secret-name`. + +### Options (Delete Subcommand) + +- `--secret-id `: Secret ID (UUID format) to delete. Can be specified multiple times or comma-separated for batch deletion. +- `--secret-name `: Secret name/key to delete (requires `--org-id`, only works if name is unique) +- `--access-token `: Bitwarden Secrets Manager access token (prefer env var `BWS_ACCESS_TOKEN`) +- `--org-id `: Organization ID (UUID) - required when using `--secret-name` (prefer env var `BWS_ORG_ID`) +- `--force`: Skip confirmation prompt (required for non-interactive use) +- `--json`: Print JSON output (includes deleted_secret_ids, count) instead of just IDs +- `--debug`: Print debug logs to stderr + +**Important**: The `--force` flag is required for non-interactive deletion to prevent accidental deletions in scripts. + +### Example Usage (Delete) + +#### Delete by Secret ID + +```bash +./bwsm_secret.sh delete --secret-id "123e4567-e89b-12d3-a456-426614174000" --access-token "$BWS_ACCESS_TOKEN" --force +``` + +#### Delete Multiple Secrets by ID + +```bash +./bwsm_secret.sh delete --secret-id "uuid1" --secret-id "uuid2" --access-token "$BWS_ACCESS_TOKEN" --force +``` + +Or comma-separated: + +```bash +./bwsm_secret.sh delete --secret-id "uuid1,uuid2,uuid3" --access-token "$BWS_ACCESS_TOKEN" --force +``` + +#### Delete by Secret Name + +```bash +./bwsm_secret.sh delete --secret-name "my-secret" --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" --force +``` + +#### Delete: JSON Output + +```bash +./bwsm_secret.sh delete --secret-id "uuid" --access-token "$BWS_ACCESS_TOKEN" --force --json +``` + +Output: + +```json +{"deleted_secret_ids":["123e4567-e89b-12d3-a456-426614174000"],"count":1} +``` + +#### Delete: Interactive Confirmation + +When running interactively (TTY), you'll be prompted for confirmation unless `--force` is used: + +```bash +./bwsm_secret.sh delete --secret-id "uuid" --access-token "$BWS_ACCESS_TOKEN" +# Prompts: Delete 1 secret? [y/N]: +``` + +### Output (Delete) + +By default, the `delete` subcommand prints **only the deleted secret IDs** to stdout (one per line, suitable for piping): + +```bash +DELETED_IDS=$(./bwsm_secret.sh delete --secret-id "uuid1" --secret-id "uuid2" --access-token "$BWS_ACCESS_TOKEN" --force) +echo "Deleted: $DELETED_IDS" +``` + +Use `--json` for structured output with metadata. + +### Error Handling (Delete) + +#### Delete: Missing Required Parameters + +```bash +$ ./bwsm_secret.sh delete +Config error: missing secret identifier. +Provide at least one secret identifier via: + - CLI: --secret-id [--secret-id ...] (can specify multiple) + - CLI: --secret-name --org-id (only works if name is unique) + - Env: BWS_ACCESS_TOKEN and BWS_SECRET_ID (or BWS_SECRET_NAME) + - Env: BWS_ORG_ID (required for --secret-name) +``` + +#### Missing Force Flag (Non-Interactive) + +```bash +$ echo "test" | ./bwsm_secret.sh delete --secret-id "uuid" --access-token "$BWS_ACCESS_TOKEN" +Error: --force flag is required for non-interactive deletion. +This prevents accidental deletions in scripts. +``` + +#### Delete: Invalid UUID Format + +```bash +$ ./bwsm_secret.sh delete --secret-id "invalid-uuid" --access-token "$BWS_ACCESS_TOKEN" --force +Error: Secret ID must be a valid UUID format. Got: 'invalid-uuid'. +``` + +#### Secret Not Found (404) + +```bash +$ ./bwsm_secret.sh delete --secret-id "00000000-0000-0000-0000-000000000000" --access-token "$BWS_ACCESS_TOKEN" --force +Error: Secret(s) not found. [error details] +``` + +#### Multiple Secrets with Same Name (Safety) + +For safety, deletion by name is only allowed when the secret name is unique. If multiple secrets share the same name, you must use `--secret-id` explicitly: + +```bash +$ ./bwsm_secret.sh delete --secret-name "my-secret" --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" --force +Error: Cannot delete by name when multiple secrets share the same name. Found 12 secrets with name/key 'my-secret': + - Secret ID: , Project ID: + - Secret ID: , Project ID: + ... + +For safety, use --secret-id to explicitly specify which secret to delete. +``` + +**Note**: Deletion by name is only allowed when the secret name is unique. If multiple secrets share the same name, you must use `--secret-id` explicitly. This prevents accidental deletion of the wrong secret. + +## Update Subcommand + +The `update` subcommand modifies existing secrets in Bitwarden Secrets Manager. You can update the secret key, value, note, and project associations. Only provided fields are updated; existing values are preserved for omitted fields. + +### Update Configuration Sources + +The script supports multiple configuration sources (checked in priority order): + +#### 1. Update: Command-Line Arguments (Highest Priority) + +```bash +./bwsm_secret.sh update --secret-id --key "new-key" --value "new-value" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### 2. Update: Environment Variables + +```bash +export BWS_ACCESS_TOKEN="..." +export BWS_SECRET_ID="..." +./bwsm_secret.sh update --key "new-key" --value "new-value" +``` + +**Note**: The script uses `BWS_ORG_ID` environment variable for organization ID. + +### Options (Update Subcommand) + +- `--secret-id `: Secret ID (UUID format) to update +- `--secret-name `: Secret name/key to update (requires `--org-id`, only works if name is unique) +- `--key `: New secret key/name (optional, only updates if provided) +- `--value `: New secret value (optional, can read from stdin if not provided) +- `--note `: New note/description (optional, only updates if provided) +- `--project-ids [,uuid2,...]`: New project IDs (comma-separated, optional, only updates if provided) +- `--project-id-filter `: Project ID to disambiguate when multiple secrets share the same name (for --secret-name lookup only) +- `--access-token `: Bitwarden Secrets Manager access token (prefer env var `BWS_ACCESS_TOKEN`) +- `--org-id `: Organization ID (UUID) - required when using `--secret-name` (prefer env var `BWS_ORG_ID`) +- `--json`: Print JSON output (includes secret_id, key, value, note, project_id) instead of just secret ID +- `--debug`: Print debug logs to stderr + +### Update Examples + +#### Update by Secret ID + +```bash +# Update only the value +./bwsm_secret.sh update --secret-id --value "new-value" --access-token "$BWS_ACCESS_TOKEN" + +# Update key and value +./bwsm_secret.sh update --secret-id --key "new-key" --value "new-value" --access-token "$BWS_ACCESS_TOKEN" + +# Update note only +./bwsm_secret.sh update --secret-id --note "Updated description" --access-token "$BWS_ACCESS_TOKEN" + +# Update project IDs +./bwsm_secret.sh update --secret-id --project-ids , --access-token "$BWS_ACCESS_TOKEN" + +# Update multiple fields +./bwsm_secret.sh update --secret-id --key "new-key" --value "new-value" --note "New note" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Update by Secret Name (Unique Name Only) + +```bash +# Update value by name (only works if name is unique) +./bwsm_secret.sh update --secret-name "my-secret" --org-id "$BWS_ORG_ID" --value "new-value" --access-token "$BWS_ACCESS_TOKEN" +``` + +**Note**: Update by name is only allowed when the secret name is unique. If multiple secrets share the same name, you must use `--secret-id` explicitly. + +#### Update with Value from Stdin + +```bash +# Read value from stdin +echo "new-value" | ./bwsm_secret.sh update --secret-id --access-token "$BWS_ACCESS_TOKEN" + +# Or pipe from another command +cat secret.txt | ./bwsm_secret.sh update --secret-id --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Update: JSON Output + +```bash +./bwsm_secret.sh update --secret-id --key "new-key" --value "new-value" --access-token "$BWS_ACCESS_TOKEN" --json +``` + +Output: + +```json +{"secret_id":"","key":"new-key","value":"new-value","org_id":"","note":"...","project_id":""} +``` + +### Partial Updates + +The update command supports partial updates. Only fields that are explicitly provided are updated; all other fields remain unchanged: + +```bash +# Only update the value, keep key and note unchanged +./bwsm_secret.sh update --secret-id --value "new-value" --access-token "$BWS_ACCESS_TOKEN" + +# Only update the key, keep value and note unchanged +./bwsm_secret.sh update --secret-id --key "new-key" --access-token "$BWS_ACCESS_TOKEN" +``` + +### Update Error Handling + +#### Update: Missing Required Parameters + +```bash +$ ./bwsm_secret.sh update +Config error: missing secret identifier. +Provide at least one secret identifier via: + - CLI: --secret-id + - CLI: --secret-name --org-id +``` + +#### Update: No Update Fields Provided + +```bash +$ ./bwsm_secret.sh update --secret-id --access-token "$BWS_ACCESS_TOKEN" +Config error: no update fields provided. +Provide at least one field to update via: + - CLI: --key + - CLI: --value (or pipe to stdin) + - CLI: --note + - CLI: --project-ids [,uuid2,...] +``` + +### Update: Invalid UUID Format + +```bash +$ ./bwsm_secret.sh update --secret-id "invalid-uuid" --key "new-key" --access-token "$BWS_ACCESS_TOKEN" +Error: Secret ID must be a valid UUID format. Got: 'invalid-uuid'. +``` + +### Update: Secret Not Found (404) + +```bash +$ ./bwsm_secret.sh update --secret-id "00000000-0000-0000-0000-000000000000" --key "new-key" --access-token "$BWS_ACCESS_TOKEN" +Error: Secret not found. [error details] +``` + +#### Update: Multiple Secrets with Same Name (Safety) + +For safety, update by name is only allowed when the secret name is unique. If multiple secrets share the same name, you must use `--secret-id` explicitly: + +```bash +$ ./bwsm_secret.sh update --secret-name "my-secret" --org-id "$BWS_ORG_ID" --value "new-value" --access-token "$BWS_ACCESS_TOKEN" +Error: Cannot update by name when multiple secrets share the same name. Found 12 secrets with name/key 'my-secret': + - Secret ID: , Project ID: + - Secret ID: , Project ID: + ... + +For safety, use --secret-id to explicitly specify which secret to update. +``` + +**Note**: Even with `--project-id-filter`, update by name is not allowed when duplicates exist. This prevents accidental update of the wrong secret. Always use `--secret-id` when multiple secrets share the same name. + +## List Subcommand + +The `list` subcommand displays all secrets in a Bitwarden organization. You can filter by project ID and/or key pattern, and output in table (default) or JSON format. + +### List Configuration Sources + +The script supports multiple configuration sources (checked in priority order): + +#### 1. List: Command-Line Arguments (Highest Priority) + +```bash +./bwsm_secret.sh list --org-id --access-token "$BWS_ACCESS_TOKEN" +``` + +#### 2. List: Environment Variables + +```bash +export BWS_ACCESS_TOKEN="..." +export BWS_ORG_ID="..." +./bwsm_secret.sh list +``` + +### Options (List Subcommand) + +- `--access-token `: Bitwarden Secrets Manager access token (prefer env var `BWS_ACCESS_TOKEN`) +- `--org-id `: Organization ID (UUID, required) (prefer env var `BWS_ORG_ID`) +- `--project-id `: Filter by project ID (UUID, optional). Note: Requires fetching each secret individually, which is slower. +- `--key-pattern `: Filter by key name pattern (substring match, case-sensitive, optional) +- `--json`: Print JSON output (array of secret objects) instead of table format +- `--debug`: Print debug logs to stderr + +### List Examples + +#### Basic List + +```bash +# List all secrets in the organization +./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" +``` + +Output (table format): + +```table +ID Key Project ID Note +------------------------------------ -------------------- ------------------------------------ ------------------------------ +550e8400-e29b-41d4-a716-446655440000 my-secret a1b2c3d4-e5f6-7890-abcd-ef1234567890 Production secret +660e8400-e29b-41d4-a716-446655440001 another-secret b2c3d4e5-f6a7-8901-bcde-f12345678901 Development secret +``` + +#### Filter by Project ID + +```bash +# List only secrets in a specific project +./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --project-id "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" +``` + +**Note**: Filtering by `--project-id` requires fetching each secret individually to check its project association, which is slower than listing all secrets. This is because the Bitwarden SDK's `list()` API doesn't return project information. + +#### Filter by Key Pattern + +```bash +# List secrets whose keys contain "api" +./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --key-pattern "api" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### Combine Filters + +```bash +# List secrets in a project whose keys contain "prod" +./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --project-id "$PROJECT_ID" --key-pattern "prod" --access-token "$BWS_ACCESS_TOKEN" +``` + +#### List: JSON Output + +```bash +./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --access-token "$BWS_ACCESS_TOKEN" --json +``` + +Output: + +```json +[ + {"secret_id":"550e8400-e29b-41d4-a716-446655440000","key":"my-secret","project_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","note":"Production secret"}, + {"secret_id":"660e8400-e29b-41d4-a716-446655440001","key":"another-secret","project_id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","note":"Development secret"} +] +``` + +### List Error Handling + +#### List: Missing Required Parameters + +```bash +$ ./bwsm_secret.sh list +Config error: missing organization ID. +Provide organization ID via one of: + - CLI: --org-id + - Env: BWS_ORG_ID +``` + +#### List: Invalid UUID Format + +```bash +$ ./bwsm_secret.sh list --org-id "invalid-uuid" --access-token "$BWS_ACCESS_TOKEN" +Error: Organization ID must be a valid UUID format. Got: 'invalid-uuid'. +``` + +#### List: Authentication Error + +```bash +$ ./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --access-token "invalid" +Error: Authentication failed. Invalid access token +``` + +#### List: Empty Results + +If filtering results in no secrets, the command returns exit code 0 (success) with empty output: + +```bash +$ ./bwsm_secret.sh list --org-id "$BWS_ORG_ID" --key-pattern "nonexistent" --access-token "$BWS_ACCESS_TOKEN" +No secrets found. +``` + +### Performance Note + +Filtering by `--project-id` requires fetching each secret individually via `get()` calls to check its project association, as the SDK's `list()` API doesn't return project information. This means: + +- **Without `--project-id` filter**: Fast - single API call to list all secrets +- **With `--project-id` filter**: Slower - one API call per secret to check project association + +For large organizations with many secrets, consider using `--key-pattern` first to reduce the number of secrets before applying the project filter. + ## Direct Python Usage You can also run the Python script directly: @@ -153,7 +763,7 @@ echo "Secret: $SECRET" Use `--json` for structured output with metadata. -## Exit Codes +## Exit Codes (All Subcommands) - `0`: Success - `2`: Configuration/usage error (missing credentials, invalid subcommand) @@ -161,7 +771,7 @@ Use `--json` for structured output with metadata. - `4`: Secret not found - `5`: SDK/runtime error -## Error Handling +## Error Handling (Get Subcommand) ### Missing Credentials @@ -272,4 +882,3 @@ When executed from URL: - Temporary files are automatically cleaned up on exit - The script works in both local and URL execution contexts - For backward compatibility, omitting the subcommand defaults to `get` -- Future subcommands (`create`, `update`, `delete`, `list`) will be added in upcoming releases diff --git a/scripts/bwsm_secret.py b/scripts/bwsm_secret.py index e0948fa..1ab66e5 100755 --- a/scripts/bwsm_secret.py +++ b/scripts/bwsm_secret.py @@ -6,9 +6,9 @@ Subcommands: get - Get a secret value - create - Create a new secret (coming soon) - update - Update an existing secret (coming soon) - delete - Delete secret(s) (coming soon) + create - Create a new secret + update - Update an existing secret + delete - Delete secret(s) list - List all secrets (coming soon) Usage: @@ -48,17 +48,24 @@ import os import re import sys -from typing import Optional, Tuple +import uuid +from typing import Optional, Tuple, List from bitwarden_sdk import BitwardenClient -def eprint(*args: object) -> None: - print(*args, file=sys.stderr) +def eprint(*args: object, **kwargs) -> None: + print(*args, file=sys.stderr, **kwargs) -def is_uuid(value: str) -> bool: - """Check if a string is a valid UUID format.""" +def is_uuid(value) -> bool: + """Check if a value is a valid UUID format (string or UUID object).""" + # Convert UUID object to string if needed + if isinstance(value, uuid.UUID): + value = str(value) + elif not isinstance(value, str): + return False + uuid_pattern = re.compile( r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE @@ -86,8 +93,7 @@ def resolve_config(args: argparse.Namespace) -> Tuple[Optional[str], Optional[st if args.access_token is not None and secret_identifier: # If CLI args are provided but empty, fall through to env vars if args.access_token and secret_identifier: - # Support both BWS_ORG_ID (standard) and BW_ORGANIZATION_ID (legacy) - org_id = args.org_id or os.getenv("BWS_ORG_ID") or os.getenv("BW_ORGANIZATION_ID") + org_id = args.org_id or os.getenv("BWS_ORG_ID") return args.access_token, secret_identifier, org_id, identifier_type, "cli" # If one is empty, try to fill from env access_token = args.access_token if args.access_token else os.getenv("BWS_ACCESS_TOKEN") @@ -100,16 +106,14 @@ def resolve_config(args: argparse.Namespace) -> Tuple[Optional[str], Optional[st secret_identifier = os.getenv("BWS_SECRET_NAME") identifier_type = "name" if access_token and secret_identifier: - # Support both BWS_ORG_ID (standard) and BW_ORGANIZATION_ID (legacy) - org_id = args.org_id or os.getenv("BWS_ORG_ID") or os.getenv("BW_ORGANIZATION_ID") + org_id = args.org_id or os.getenv("BWS_ORG_ID") return access_token, secret_identifier, org_id, identifier_type, "cli" # 2) Environment env_token = os.getenv("BWS_ACCESS_TOKEN") env_secret_id = os.getenv("BWS_SECRET_ID") env_secret_name = os.getenv("BWS_SECRET_NAME") - # Support both BWS_ORG_ID (standard) and BW_ORGANIZATION_ID (legacy) - env_org_id = os.getenv("BWS_ORG_ID") or os.getenv("BW_ORGANIZATION_ID") + env_org_id = os.getenv("BWS_ORG_ID") if env_token: if env_secret_id: @@ -121,10 +125,213 @@ def resolve_config(args: argparse.Namespace) -> Tuple[Optional[str], Optional[st return None, None, None, None, "missing" +def read_value_from_stdin() -> str: + """ + Read secret value from stdin if available (non-TTY). + Returns the value as a string. + Raises RuntimeError if stdin is empty or a TTY. + """ + import sys + + # Check if stdin is a TTY (interactive terminal) + if sys.stdin.isatty(): + raise RuntimeError("VALUE_REQUIRED: Secret value is required. Provide via --value or pipe to stdin.") + + # Read from stdin + value = sys.stdin.read().strip() + + if not value: + raise RuntimeError("VALUE_REQUIRED: Secret value is required. Provide via --value or pipe to stdin.") + + return value + + +def resolve_create_config(args: argparse.Namespace) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], str]: + """ + Resolve (access_token, org_id, key, value, note, project_ids, source_label). + Returns None for missing required values. + """ + # Resolve access token + access_token = None + if args.access_token is not None: + if args.access_token: + access_token = args.access_token + else: + # Empty string, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + else: + # Not provided, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + + # Resolve organization ID + org_id = None + if args.org_id is not None: + if args.org_id: + org_id = args.org_id + else: + # Empty string, try env vars + org_id = os.getenv("BWS_ORG_ID") + else: + # Not provided, try env var + org_id = os.getenv("BWS_ORG_ID") + + # Key is required and comes from CLI only + key = args.key if args.key else None + + # Value can come from CLI or stdin (handled separately) + value = args.value if args.value else None + + # Note is optional + note = args.note if args.note else None + + # Project IDs are optional + project_ids = args.project_ids if args.project_ids else None + + # Determine source + source = "cli" if (args.access_token or args.org_id or args.key) else "env" + + return access_token, org_id, key, value, note, project_ids, source + + +def resolve_update_config(args: argparse.Namespace) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], str]: + """ + Resolve (access_token, secret_id, secret_name, org_id, key, value, note, project_ids, source_label). + Returns None for missing optional values. + """ + # Resolve access token + access_token = None + if args.access_token is not None: + if args.access_token: + access_token = args.access_token + else: + # Empty string, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + else: + # Not provided, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + + # Resolve organization ID + org_id = None + if args.org_id is not None: + if args.org_id: + org_id = args.org_id + else: + # Empty string, try env var + org_id = os.getenv("BWS_ORG_ID") + else: + # Not provided, try env var + org_id = os.getenv("BWS_ORG_ID") + + # Secret ID (single value) + secret_id = args.secret_id if args.secret_id else None + + # Secret name (single value) + secret_name = args.secret_name if args.secret_name else None + + # Update fields (all optional) + key = args.key if args.key else None + value = args.value if args.value else None + note = args.note if args.note else None + project_ids = args.project_ids if args.project_ids else None + + # Determine source + source = "cli" if (args.access_token or args.org_id or args.secret_id or args.secret_name or args.key or args.value or args.note or args.project_ids) else "env" + + return access_token, secret_id, secret_name, org_id, key, value, note, project_ids, source + + +def resolve_delete_config(args: argparse.Namespace) -> Tuple[Optional[str], List[str], Optional[str], Optional[str], str]: + """ + Resolve (access_token, secret_ids, secret_name, org_id, source_label). + secret_ids: list of UUID strings (from --secret-id, can be comma-separated) + secret_name: single name string (from --secret-name) + """ + # Resolve access token + access_token = None + if args.access_token is not None: + if args.access_token: + access_token = args.access_token + else: + # Empty string, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + else: + # Not provided, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + + # Resolve organization ID + org_id = None + if args.org_id is not None: + if args.org_id: + org_id = args.org_id + else: + # Empty string, try env var + org_id = os.getenv("BWS_ORG_ID") + else: + # Not provided, try env var + org_id = os.getenv("BWS_ORG_ID") + + # Parse secret IDs (can be multiple --secret-id flags or comma-separated) + secret_ids = [] + if args.secret_id: + for sid in args.secret_id: + # Handle comma-separated values + for s in sid.split(","): + s = s.strip() + if s: + secret_ids.append(s) + + # Secret name (single value) + secret_name = args.secret_name if args.secret_name else None + + # Determine source + source = "cli" if (args.access_token or args.org_id or args.secret_id or args.secret_name) else "env" + + return access_token, secret_ids, secret_name, org_id, source + + +def resolve_list_config(args: argparse.Namespace) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str], str]: + """ + Resolve (access_token, org_id, project_id, key_pattern, source_label). + """ + # Resolve access token + access_token = None + if args.access_token is not None: + if args.access_token: + access_token = args.access_token + else: + # Empty string, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + else: + # Not provided, try env var + access_token = os.getenv("BWS_ACCESS_TOKEN") + + # Resolve organization ID + org_id = None + if args.org_id is not None: + if args.org_id: + org_id = args.org_id + else: + # Empty string, try env var + org_id = os.getenv("BWS_ORG_ID") + else: + # Not provided, try env var + org_id = os.getenv("BWS_ORG_ID") + + # Extract project_id and key_pattern + project_id = getattr(args, "project_id", None) + key_pattern = getattr(args, "key_pattern", None) + + # Determine source + source = "cli" if (args.access_token or args.org_id or project_id or key_pattern) else "env" + + return access_token, org_id, project_id, key_pattern, source + + def find_secret_by_name(client, secret_name: str, org_id: Optional[str] = None, debug: bool = False) -> str: """ Find a secret by name by listing all secrets and matching the key/name. Returns the secret ID if found. + Raises RuntimeError with MULTIPLE_SECRETS if multiple matches found. """ if debug: eprint(f"Listing secrets to find secret with name/key='{secret_name}'...") @@ -151,16 +358,158 @@ def find_secret_by_name(client, secret_name: str, org_id: Optional[str] = None, if debug: eprint(f"Found {len(secrets)} secrets, searching for name/key='{secret_name}'...") - # Search for secret by key (name) + # Find all secrets with matching key + matching_secrets = [] for secret in secrets: secret_key = getattr(secret, "key", None) secret_id = getattr(secret, "id", None) - if secret_key == secret_name: + if secret_key == secret_name and secret_id: + # Convert UUID object to string if needed + secret_id_str = str(secret_id) if isinstance(secret_id, uuid.UUID) else secret_id + matching_secrets.append(secret_id_str) + + if not matching_secrets: + raise RuntimeError(f"NOT_FOUND: Secret with name/key '{secret_name}' not found") + + # If only one match, return it + if len(matching_secrets) == 1: + if debug: + eprint(f"Found secret: name='{secret_name}', id='{matching_secrets[0]}'") + return matching_secrets[0] + + # Multiple matches found + if debug: + eprint(f"Found {len(matching_secrets)} secrets with name/key='{secret_name}'") + + # Multiple matches - cannot disambiguate + # Fetch project info for all matches to show in error message + secret_details = [] + for secret_id in matching_secrets: + try: + secret_response = client.secrets().get(id=secret_id) + if getattr(secret_response, "success", False) and getattr(secret_response, "data", None): + secret_data = secret_response.data + secret_project_id = getattr(secret_data, "project_id", None) + project_str = str(secret_project_id) if secret_project_id else "unknown" + secret_details.append(f" - Secret ID: {secret_id}, Project ID: {project_str}") + except: + secret_details.append(f" - Secret ID: {secret_id}, Project ID: unknown") + + error_msg = f"MULTIPLE_SECRETS: Found {len(matching_secrets)} secrets with name/key '{secret_name}':\n" + error_msg += "\n".join(secret_details) + error_msg += f"\n\nTo disambiguate, use --secret-id with one of the secret IDs above." + raise RuntimeError(error_msg) + + +def check_duplicate_secret(client, secret_key: str, org_id: str, project_ids: list, debug: bool = False) -> Optional[str]: + """ + Check if a secret with the same key already exists in any of the specified projects. + Returns the secret ID if found, None otherwise. + + Note: The list() API doesn't return project associations, so we need to fetch + each matching secret individually to check its projects. + """ + if debug: + eprint(f"Checking for duplicate secret with key='{secret_key}' in organization id={org_id}...") + + list_response = client.secrets().list(organization_id=org_id) + if not getattr(list_response, "success", False): + msg = getattr(list_response, "error_message", "Unknown error") + raise RuntimeError(f"LIST_ERROR: {msg}") + + data = getattr(list_response, "data", None) + if not data: + raise RuntimeError("LIST_ERROR: No data returned from secrets list") + + # Get the list of secrets + secrets = getattr(data, "data", []) or [] + + if debug: + eprint(f"Found {len(secrets)} secrets, checking for key='{secret_key}' in projects {[str(p) for p in project_ids]}...") + + # Convert project_ids to strings for comparison + project_id_strs = {str(pid) for pid in project_ids} + + # Find all secrets with matching key + matching_secret_ids = [] + for secret in secrets: + secret_key_attr = getattr(secret, "key", None) + secret_id = getattr(secret, "id", None) + + if secret_key_attr == secret_key and secret_id: + matching_secret_ids.append(secret_id) + + if not matching_secret_ids: + if debug: + eprint(f"No secrets found with key='{secret_key}'") + return None + + if debug: + eprint(f"Found {len(matching_secret_ids)} secret(s) with key='{secret_key}', checking project associations...") + + # Fetch each matching secret to check its project associations + # The list() API doesn't include project info, so we need to get() each one + for secret_id in matching_secret_ids: + try: + secret_response = client.secrets().get(id=secret_id) + if not getattr(secret_response, "success", False): + if debug: + eprint(f"Warning: Failed to fetch secret id={secret_id}") + continue + + secret_data = getattr(secret_response, "data", None) + if not secret_data: + if debug: + eprint(f"Warning: No data returned for secret id={secret_id}") + continue + + # Get project_id from the secret object + # The SDK uses 'project_id' (singular) as a single UUID value + secret_project_id = getattr(secret_data, "project_id", None) + + if secret_project_id is None: + # Try alternative attribute names (for backward compatibility) + for attr_name in ["project_ids", "projects", "projectId"]: + secret_project_id = getattr(secret_data, attr_name, None) + if secret_project_id is not None: + break + + if secret_project_id is None: + if debug: + eprint(f"Warning: Secret '{secret_key}' (id={secret_id}) has no project_id attribute") + continue + + # Convert project_id to string for comparison + # It could be a UUID object, a string, or a list (though SDK seems to use single value) + if isinstance(secret_project_id, uuid.UUID): + secret_project_id_str = str(secret_project_id) + elif isinstance(secret_project_id, list): + # Handle list case (if SDK ever returns multiple projects) + secret_project_ids = [str(p) if isinstance(p, uuid.UUID) else str(p) for p in secret_project_id] + if any(pid in project_id_strs for pid in secret_project_ids): + if debug: + eprint(f"Found duplicate secret: key='{secret_key}', id='{secret_id}', projects={secret_project_ids}") + return str(secret_id) + continue + else: + secret_project_id_str = str(secret_project_id) + + # Check if this secret's project_id matches any of the specified projects + if secret_project_id_str in project_id_strs: + if debug: + eprint(f"Found duplicate secret: key='{secret_key}', id='{secret_id}', project='{secret_project_id_str}'") + return str(secret_id) + + except Exception as exc: if debug: - eprint(f"Found secret: name='{secret_name}', id='{secret_id}'") - return secret_id + eprint(f"Warning: Error checking secret id={secret_id}: {exc}") + import traceback + eprint(f"Traceback: {traceback.format_exc()}") + continue - raise RuntimeError(f"NOT_FOUND: Secret with name/key '{secret_name}' not found") + if debug: + eprint(f"No duplicate found for key='{secret_key}' in the specified projects") + return None def get_secret_value(access_token: str, secret_identifier: str, identifier_type: Optional[str], org_id: Optional[str] = None, debug: bool = False) -> str: @@ -189,7 +538,7 @@ def get_secret_value(access_token: str, secret_identifier: str, identifier_type: else: eprint(f"Searching for secret name='{secret_identifier}'...") if not org_id: - raise RuntimeError("ORG_ID_REQUIRED: Organization ID is required when using secret name (provide via --org-id or BWS_ORG_ID)") + raise RuntimeError("ORG_ID_REQUIRED: Organization ID is required when using secret name (provide via --org-id or BWS_ORG_ID environment variable)") secret_id = find_secret_by_name(client, secret_identifier, org_id=org_id, debug=debug) elif identifier_type == "id": # Validate that it's a UUID @@ -215,165 +564,1547 @@ def get_secret_value(access_token: str, secret_identifier: str, identifier_type: raise RuntimeError(f"NOT_FOUND_OR_ERROR: {msg}") -def build_parser(subcommand: str) -> argparse.ArgumentParser: - """Build argument parser for the given subcommand.""" - if subcommand == "get": - p = argparse.ArgumentParser( - description="Get a specific secret value from Bitwarden Secrets Manager (Bitwarden SDK)." - ) - p.add_argument("--secret-id", help="Secret ID (UUID format required) to fetch") - p.add_argument("--secret-name", help="Secret name/key to fetch (requires --org-id)") - p.add_argument( - "--access-token", - help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", - ) - p.add_argument( - "--org-id", - help="Organization ID (UUID) - required when using --secret-name, optional otherwise (prefer env var BWS_ORG_ID or BW_ORGANIZATION_ID)", - ) - p.add_argument( - "--json", - action="store_true", - help="Print JSON output (includes secret_id, source). Value still included.", - ) - p.add_argument( - "--debug", - action="store_true", - help="Print debug logs to stderr.", - ) - elif subcommand in ("create", "update", "delete", "list"): - # Placeholder parsers for future subcommands - p = argparse.ArgumentParser( - description=f"{subcommand.capitalize()} secret(s) in Bitwarden Secrets Manager (coming soon)." - ) - p.add_argument( - "--access-token", - help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", - ) - p.add_argument( - "--org-id", - help="Organization ID (UUID) (prefer env var BWS_ORG_ID or BW_ORGANIZATION_ID)", - ) - p.add_argument( - "--debug", - action="store_true", - help="Print debug logs to stderr.", - ) - else: - p = argparse.ArgumentParser( - description="Manage secrets in Bitwarden Secrets Manager (Bitwarden SDK)." - ) - return p - +def create_secret( + access_token: str, + organization_id: str, + key: str, + value: str, + note: Optional[str] = None, + project_ids: Optional[str] = None, + allow_duplicate: bool = False, + debug: bool = False, +) -> str: + """ + Authenticate with access token and create a new secret. + Returns the created secret ID. + Raises RuntimeError with a categorized message on failures. + """ + client = BitwardenClient() -def handle_get(args: argparse.Namespace) -> int: - """Handle the 'get' subcommand.""" - # Validate that only one of --secret-id or --secret-name is provided - if args.secret_id and args.secret_name: - eprint("Error: Cannot specify both --secret-id and --secret-name. Use only one.") - return 2 + if debug: + eprint("Authenticating with access token...") - access_token, secret_identifier, org_id, identifier_type, source = resolve_config(args) + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") - if not access_token or not secret_identifier: - eprint("Config error: missing Bitwarden credentials.") - eprint("Provide both access token and secret identifier via one of:") - eprint(" - CLI: --access-token ... --secret-id [--org-id ...]") - eprint(" - CLI: --access-token ... --secret-name --org-id ") - eprint(" - Env: BWS_ACCESS_TOKEN and BWS_SECRET_ID (or BWS_SECRET_NAME)") - eprint(" - Env: BWS_ORG_ID or BW_ORGANIZATION_ID (required for --secret-name)") - eprint("") - # Debug info - if args.debug: - eprint("Debug info:") - eprint(f" CLI --access-token: {'provided' if args.access_token is not None else 'not provided'} ({'empty' if args.access_token == '' else 'has value'})") - eprint(f" CLI --secret-id: {'provided' if args.secret_id is not None else 'not provided'} ({'empty' if args.secret_id == '' else 'has value'})") - eprint(f" CLI --secret-name: {'provided' if args.secret_name is not None else 'not provided'} ({'empty' if args.secret_name == '' else 'has value'})") - eprint(f" Env BWS_ACCESS_TOKEN: {'set' if os.getenv('BWS_ACCESS_TOKEN') else 'not set'}") - eprint(f" Env BWS_SECRET_ID: {'set' if os.getenv('BWS_SECRET_ID') else 'not set'}") - eprint(f" Env BWS_SECRET_NAME: {'set' if os.getenv('BWS_SECRET_NAME') else 'not set'}") - return 2 + # Validate organization_id is a UUID + if not is_uuid(organization_id): + raise RuntimeError(f"INVALID_ID: Organization ID must be a valid UUID format. Got: '{organization_id}'.") + # Convert organization_id to UUID object try: - value = get_secret_value(access_token, secret_identifier, identifier_type, org_id=org_id, debug=args.debug) + org_uuid = uuid.UUID(organization_id) + except ValueError: + raise RuntimeError(f"INVALID_ID: Organization ID must be a valid UUID format. Got: '{organization_id}'.") + + # Parse and validate project_ids (required) + if not project_ids: + raise RuntimeError("PROJECT_ID_REQUIRED: At least one project ID is required. Secrets must be created within a project, not at the organization level.") + + project_id_list = [pid.strip() for pid in project_ids.split(",") if pid.strip()] + if not project_id_list: + raise RuntimeError("PROJECT_ID_REQUIRED: At least one project ID is required. Secrets must be created within a project, not at the organization level.") + + project_uuid_list = [] + for pid in project_id_list: + if not is_uuid(pid): + raise RuntimeError(f"INVALID_ID: Project ID must be a valid UUID format. Got: '{pid}'.") + try: + project_uuid_list.append(uuid.UUID(pid)) + except ValueError: + raise RuntimeError(f"INVALID_ID: Project ID must be a valid UUID format. Got: '{pid}'.") + + # Check for duplicate secret (unless --allow-duplicate is set) + if not allow_duplicate: + existing_secret_id = check_duplicate_secret(client, key, organization_id, project_uuid_list, debug=debug) + if existing_secret_id: + raise RuntimeError(f"DUPLICATE_SECRET: A secret with key '{key}' already exists in one or more of the specified projects (secret ID: {existing_secret_id}). Use --allow-duplicate to create anyway.") - if args.json: - # Minimal JSON, no extra deps - org_json = f",\"org_id\":\"{org_id}\"" if org_id else "" - identifier_json = f"\"secret_{identifier_type}\":\"{secret_identifier}\"," - out = ( - "{" - f"{identifier_json}" - f"\"source\":\"{source}\"{org_json}," - f"\"value\":\"{value.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" - "}" - ) - print(out) - else: - # value-only output for easy piping - print(value) + if debug: + eprint(f"Creating secret with key='{key}' in organization id={organization_id}...") + if note: + eprint(f" Note: {note}") + if project_uuid_list: + eprint(f" Project IDs: {[str(p) for p in project_uuid_list]}") + + # Call SDK create method + create_response = client.secrets().create( + organization_id=org_uuid, + key=key, + value=value, + note=note, + project_ids=project_uuid_list, + ) - return 0 + if getattr(create_response, "success", False) and getattr(create_response, "data", None): + secret_data = create_response.data + secret_id = getattr(secret_data, "id", None) + if secret_id is None: + raise RuntimeError("SDK_ERROR: Secret created but ID is empty/null") + return str(secret_id) - except RuntimeError as exc: - msg = str(exc) + msg = getattr(create_response, "error_message", "Unknown error") - if msg.startswith("AUTH_ERROR:"): - eprint(f"Error: Authentication failed. {msg[len('AUTH_ERROR: '):]}") - return 3 + # Check for 404 errors (organization not found, invalid org ID, or permission issues) + if "404" in msg or "not found" in msg.lower() or "Resource not found" in msg: + raise RuntimeError(f"NOT_FOUND: {msg}. This may indicate an invalid organization ID or insufficient permissions.") - if msg.startswith("NOT_FOUND_OR_ERROR:") or msg.startswith("NOT_FOUND:"): - error_detail = msg[len('NOT_FOUND_OR_ERROR: '):] if msg.startswith('NOT_FOUND_OR_ERROR:') else msg[len('NOT_FOUND: '):] - eprint(f"Error: Secret not found. {error_detail}") - return 4 + raise RuntimeError(f"SDK_ERROR: {msg}") - if msg.startswith("ORG_ID_REQUIRED:"): - eprint(f"Error: {msg[len('ORG_ID_REQUIRED: '):]}") - eprint("When using --secret-name, --org-id is required.") - return 2 - if msg.startswith("INVALID_ID:"): - eprint(f"Error: {msg[len('INVALID_ID: '):]}") - return 2 +def resolve_secret_ids( + access_token: str, + secret_ids: List[str], + secret_name: Optional[str], + org_id: Optional[str], + debug: bool = False, +) -> List[str]: + """ + Resolve all secret identifiers to a list of secret IDs (UUIDs). + Handles both --secret-id (direct) and --secret-name (resolved) inputs. + Returns a list of secret IDs ready for deletion. + """ + client = BitwardenClient() - if msg.startswith("LIST_ERROR:"): - eprint(f"Error: Failed to list secrets. {msg[len('LIST_ERROR: '):]}") - return 5 + if debug: + eprint("Authenticating with access token...") - if msg.startswith("SDK_ERROR:"): - eprint(f"Error: SDK returned unexpected data. {msg[len('SDK_ERROR: '):]}") - return 5 + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") - eprint(f"Error: {msg}") - return 5 + resolved_ids = [] - except Exception as exc: - eprint(f"Unexpected error: {exc}") - return 5 + # Add direct secret IDs (validate they're UUIDs) + for sid in secret_ids: + if not is_uuid(sid): + raise RuntimeError(f"INVALID_ID: Secret ID must be a valid UUID format. Got: '{sid}'.") + resolved_ids.append(sid) + # Resolve secret name to ID(s) + if secret_name: + if not org_id: + raise RuntimeError("ORG_ID_REQUIRED: Organization ID is required when using --secret-name (provide via --org-id or BWS_ORG_ID environment variable)") -def handle_create(args: argparse.Namespace) -> int: - """Handle the 'create' subcommand (coming soon).""" - eprint("Error: 'create' subcommand is not yet implemented.") - return 2 + if debug: + eprint(f"Resolving secret name '{secret_name}' to ID(s)...") + + try: + # Check if there are multiple secrets with this name + # We'll use find_secret_by_name but it will raise MULTIPLE_SECRETS if duplicates exist + # For safety, we don't allow deletion by name if duplicates exist + secret_id = find_secret_by_name(client, secret_name, org_id=org_id, debug=debug) + # If we get here, there's exactly one match - safe to delete + # Ensure it's a string (not UUID object) + secret_id_str = str(secret_id) if isinstance(secret_id, uuid.UUID) else secret_id + resolved_ids.append(secret_id_str) + except RuntimeError as exc: + msg = str(exc) + if msg.startswith("MULTIPLE_SECRETS:"): + # For safety, don't allow deletion by name when duplicates exist + # User must use --secret-id explicitly + raise RuntimeError(f"MULTIPLE_SECRETS_DELETE: Cannot delete by name when multiple secrets share the same name. {msg[len('MULTIPLE_SECRETS: '):]}\n\nFor safety, use --secret-id to explicitly specify which secret to delete.") + raise + + # Remove duplicates while preserving order + seen = set() + unique_ids = [] + for sid in resolved_ids: + if sid not in seen: + seen.add(sid) + unique_ids.append(sid) + if debug: + eprint(f"Resolved {len(unique_ids)} unique secret ID(s) for deletion") -def handle_update(args: argparse.Namespace) -> int: - """Handle the 'update' subcommand (coming soon).""" - eprint("Error: 'update' subcommand is not yet implemented.") - return 2 + return unique_ids -def handle_delete(args: argparse.Namespace) -> int: - """Handle the 'delete' subcommand (coming soon).""" - eprint("Error: 'delete' subcommand is not yet implemented.") - return 2 +def delete_secret( + access_token: str, + secret_ids: List[str], + debug: bool = False, +) -> List[str]: + """ + Authenticate with access token and delete secrets by their IDs. + First checks if each secret exists before attempting deletion. + Returns the list of deleted secret IDs. + Raises RuntimeError with a categorized message on failures. + """ + if not secret_ids: + raise RuntimeError("INVALID_ID: No secret IDs provided for deletion") + + client = BitwardenClient() + if debug: + eprint("Authenticating with access token...") -def handle_list(args: argparse.Namespace) -> int: - """Handle the 'list' subcommand (coming soon).""" - eprint("Error: 'list' subcommand is not yet implemented.") - return 2 + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") + + # Validate all secret IDs are UUIDs + for sid in secret_ids: + if not is_uuid(sid): + raise RuntimeError(f"INVALID_ID: Secret ID must be a valid UUID format. Got: '{sid}'.") + + # Check if secrets exist before attempting deletion + if debug: + eprint(f"Checking if {len(secret_ids)} secret(s) exist before deletion...") + + existing_secret_ids = [] + missing_secret_ids = [] + + for sid in secret_ids: + try: + secret_response = client.secrets().get(id=sid) + if getattr(secret_response, "success", False) and getattr(secret_response, "data", None): + existing_secret_ids.append(sid) + if debug: + eprint(f"Secret {sid} exists") + else: + missing_secret_ids.append(sid) + if debug: + eprint(f"Secret {sid} not found") + except Exception as exc: + # If get() fails, assume secret doesn't exist + missing_secret_ids.append(sid) + if debug: + eprint(f"Error checking secret {sid}: {exc}") + + # If any secrets don't exist, report error + if missing_secret_ids: + if len(missing_secret_ids) == 1: + raise RuntimeError(f"NOT_FOUND: Secret with ID '{missing_secret_ids[0]}' does not exist or you may not have permission to access it.") + else: + missing_list = ", ".join([f"'{sid}'" for sid in missing_secret_ids]) + raise RuntimeError(f"NOT_FOUND: Secrets with IDs {missing_list} do not exist or you may not have permission to access them.") + + # If no secrets exist to delete, return empty list + if not existing_secret_ids: + if debug: + eprint("No secrets found to delete") + return [] + + if debug: + eprint(f"Deleting {len(existing_secret_ids)} secret(s)...") + + # Call SDK delete method (accepts list of IDs) + delete_response = client.secrets().delete(ids=existing_secret_ids) + + if getattr(delete_response, "success", False): + # SDK delete returns success, return the IDs that were deleted + if debug: + eprint(f"Successfully deleted {len(existing_secret_ids)} secret(s)") + return existing_secret_ids + + msg = getattr(delete_response, "error_message", "Unknown error") + + # Check for 404 errors (secrets not found) + if "404" in msg or "not found" in msg.lower() or "Resource not found" in msg: + raise RuntimeError(f"NOT_FOUND: {msg}. One or more secrets may not exist or you may not have permission to delete them.") + + raise RuntimeError(f"SDK_ERROR: {msg}") + + +def get_secret_for_update(client, secret_id: str, debug: bool = False) -> dict: + """ + Fetch the current secret data for update operations. + Returns a dict with current secret fields: key, value, note, project_id, organization_id. + Raises RuntimeError with NOT_FOUND if secret doesn't exist. + """ + if debug: + eprint(f"Fetching current secret data for id={secret_id}...") + + secret_response = client.secrets().get(id=secret_id) + if not getattr(secret_response, "success", False): + msg = getattr(secret_response, "error_message", "Unknown error") + raise RuntimeError(f"NOT_FOUND: Secret with ID '{secret_id}' does not exist or you may not have permission to access it. {msg}") + + secret_data = getattr(secret_response, "data", None) + if not secret_data: + raise RuntimeError(f"NOT_FOUND: Secret with ID '{secret_id}' not found or has no data.") + + # Extract current values + current_key = getattr(secret_data, "key", None) + current_value = getattr(secret_data, "value", None) + current_note = getattr(secret_data, "note", None) + current_project_id = getattr(secret_data, "project_id", None) + current_organization_id = getattr(secret_data, "organization_id", None) + + # Convert UUID objects to strings + if current_project_id: + current_project_id = str(current_project_id) if isinstance(current_project_id, uuid.UUID) else current_project_id + if current_organization_id: + current_organization_id = str(current_organization_id) if isinstance(current_organization_id, uuid.UUID) else current_organization_id + + return { + "key": current_key, + "value": current_value, + "note": current_note, + "project_id": current_project_id, + "organization_id": current_organization_id, + } + + +def list_secrets( + access_token: str, + organization_id: str, + project_id: Optional[str] = None, + key_pattern: Optional[str] = None, + debug: bool = False, +) -> List[dict]: + """ + Authenticate with access token and list all secrets in the organization. + Optionally filter by project_id and/or key_pattern. + Returns a list of secret dictionaries with fields: id, key, note, project_id. + Raises RuntimeError with a categorized message on failures. + + Note: The SDK's list() API doesn't return project associations, so filtering + by project_id requires individual get() calls for each secret, which is slower. + """ + client = BitwardenClient() + + if debug: + eprint("Authenticating with access token...") + + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") + + # Validate organization_id is a UUID + if not is_uuid(organization_id): + raise RuntimeError(f"INVALID_ID: Organization ID must be a valid UUID format. Got: '{organization_id}'.") + + # Convert organization_id to UUID object + try: + org_uuid = uuid.UUID(organization_id) + except ValueError: + raise RuntimeError(f"INVALID_ID: Organization ID must be a valid UUID format. Got: '{organization_id}'.") + + # Validate project_id if provided + if project_id: + if not is_uuid(project_id): + raise RuntimeError(f"INVALID_ID: Project ID must be a valid UUID format. Got: '{project_id}'.") + + if debug: + eprint(f"Listing secrets in organization id={organization_id}...") + if project_id: + eprint(f"Filtering by project_id='{project_id}' (this requires fetching each secret individually, which is slower)...") + if key_pattern: + eprint(f"Filtering by key_pattern='{key_pattern}'...") + + # Call SDK list method + list_response = client.secrets().list(organization_id=org_uuid) + if not getattr(list_response, "success", False): + msg = getattr(list_response, "error_message", "Unknown error") + raise RuntimeError(f"LIST_ERROR: {msg}") + + data = getattr(list_response, "data", None) + if not data: + raise RuntimeError("LIST_ERROR: No data returned from secrets list") + + # Get the list of secrets + secrets = getattr(data, "data", []) or [] + + if debug: + eprint(f"Found {len(secrets)} secret(s) in organization") + + # Apply key_pattern filter first (if provided) + filtered_secrets = [] + for secret in secrets: + secret_key = getattr(secret, "key", None) + secret_id = getattr(secret, "id", None) + + if not secret_id: + if debug: + eprint(f"Warning: Secret has no ID, skipping") + continue + + # Apply key_pattern filter + if key_pattern: + if not secret_key or key_pattern not in secret_key: + continue + + # Convert UUID object to string if needed + secret_id_str = str(secret_id) if isinstance(secret_id, uuid.UUID) else secret_id + filtered_secrets.append({ + "id": secret_id_str, + "key": secret_key, + }) + + if debug: + eprint(f"After key_pattern filter: {len(filtered_secrets)} secret(s)") + + # Apply project_id filter if provided (requires fetching each secret) + if project_id: + project_id_str = str(project_id) + matching_secrets = [] + + if debug: + eprint(f"Fetching project info for {len(filtered_secrets)} secret(s) to filter by project_id...") + + for secret_info in filtered_secrets: + secret_id = secret_info["id"] + try: + secret_response = client.secrets().get(id=secret_id) + if not getattr(secret_response, "success", False): + if debug: + eprint(f"Warning: Failed to fetch secret id={secret_id}") + continue + + secret_data = getattr(secret_response, "data", None) + if not secret_data: + if debug: + eprint(f"Warning: No data returned for secret id={secret_id}") + continue + + # Get project_id from the secret object + secret_project_id = getattr(secret_data, "project_id", None) + + if secret_project_id is None: + # Try alternative attribute names (for backward compatibility) + for attr_name in ["project_ids", "projects", "projectId"]: + secret_project_id = getattr(secret_data, attr_name, None) + if secret_project_id is not None: + break + + if secret_project_id is None: + # Secret has no project_id (removed from project) + continue + + # Convert project_id to string for comparison + if isinstance(secret_project_id, uuid.UUID): + secret_project_id_str = str(secret_project_id) + elif isinstance(secret_project_id, list): + # Handle list case (if SDK ever returns multiple projects) + secret_project_ids = [str(p) if isinstance(p, uuid.UUID) else str(p) for p in secret_project_id] + if project_id_str in secret_project_ids: + secret_info["project_id"] = secret_project_id_str + secret_info["note"] = getattr(secret_data, "note", None) + matching_secrets.append(secret_info) + continue + else: + secret_project_id_str = str(secret_project_id) + + # Check if this secret's project_id matches + if secret_project_id_str == project_id_str: + secret_info["project_id"] = secret_project_id_str + secret_info["note"] = getattr(secret_data, "note", None) + matching_secrets.append(secret_info) + + except Exception as exc: + if debug: + eprint(f"Warning: Error checking secret id={secret_id}: {exc}") + continue + + filtered_secrets = matching_secrets + + if debug: + eprint(f"After project_id filter: {len(filtered_secrets)} secret(s)") + + # If project_id filter was not applied, we still need to get note for each secret + # But to avoid too many API calls, we'll only fetch notes if not filtering by project + # For now, we'll leave note as None if project_id filter wasn't used + # (The list API doesn't return notes, so we'd need to fetch each secret anyway) + + return filtered_secrets + + +def format_secrets_table(secrets: List[dict]) -> str: + """ + Format a list of secret dictionaries as a human-readable table. + Returns the formatted table as a string. + """ + if not secrets: + return "No secrets found." + + # Calculate column widths + id_width = max(len("ID"), max(len(s.get("id", "") or "") for s in secrets), 36) # UUID is 36 chars + key_width = max(len("Key"), max(len(s.get("key", "") or "") for s in secrets), 20) + project_width = max(len("Project ID"), max(len(s.get("project_id", "") or "") for s in secrets), 36) + note_width = max(len("Note"), max(len(s.get("note", "") or "") for s in secrets), 30) + + # Limit column widths to reasonable maximums + id_width = min(id_width, 36) + key_width = min(key_width, 50) + project_width = min(project_width, 36) + note_width = min(note_width, 50) + + # Build header + header = f"{'ID':<{id_width}} {'Key':<{key_width}} {'Project ID':<{project_width}} {'Note':<{note_width}}" + separator = "-" * len(header) + + # Build rows + rows = [header, separator] + for secret in secrets: + secret_id = secret.get("id", "") or "" + secret_key = secret.get("key", "") or "" + secret_project_id = secret.get("project_id", "") or "" + secret_note = secret.get("note", "") or "" + + # Truncate long values + if len(secret_key) > key_width: + secret_key = secret_key[:key_width-3] + "..." + if len(secret_note) > note_width: + secret_note = secret_note[:note_width-3] + "..." + + row = f"{secret_id:<{id_width}} {secret_key:<{key_width}} {secret_project_id:<{project_width}} {secret_note:<{note_width}}" + rows.append(row) + + return "\n".join(rows) + + +def format_secrets_json(secrets: List[dict]) -> str: + """ + Format a list of secret dictionaries as a JSON array. + Returns the formatted JSON as a string. + """ + if not secrets: + return "[]" + + json_objects = [] + for secret in secrets: + secret_id = secret.get("id", "") or "" + secret_key = secret.get("key", "") or "" + secret_project_id = secret.get("project_id", "") or "" + secret_note = secret.get("note", "") or "" + + # Escape special characters + escaped_key = secret_key.replace("\\", "\\\\").replace('"', '\\"') + escaped_note = secret_note.replace("\\", "\\\\").replace('"', '\\"') + + # Build JSON object + project_json = f',"project_id":"{secret_project_id}"' if secret_project_id else ',"project_id":null' + note_json = f',"note":"{escaped_note}"' if secret_note else ',"note":null' + + json_obj = f'{{"secret_id":"{secret_id}","key":"{escaped_key}"{project_json}{note_json}}}' + json_objects.append(json_obj) + + return "[" + ",".join(json_objects) + "]" + + +def update_secret( + access_token: str, + secret_id: str, + organization_id: str, + key: Optional[str] = None, + value: Optional[str] = None, + note: Optional[str] = None, + project_ids: Optional[str] = None, + debug: bool = False, +) -> str: + """ + Authenticate with access token and update an existing secret. + Only provided fields are updated; existing values are preserved for omitted fields. + Returns the updated secret ID. + Raises RuntimeError with a categorized message on failures. + """ + client = BitwardenClient() + + if debug: + eprint("Authenticating with access token...") + + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") + + # Validate secret ID is a UUID + if not is_uuid(secret_id): + raise RuntimeError(f"INVALID_ID: Secret ID must be a valid UUID format. Got: '{secret_id}'.") + + # Validate organization_id is a UUID + if not is_uuid(organization_id): + raise RuntimeError(f"INVALID_ID: Organization ID must be a valid UUID format. Got: '{organization_id}'.") + + # Convert organization_id to UUID object + try: + org_uuid = uuid.UUID(organization_id) + except ValueError: + raise RuntimeError(f"INVALID_ID: Organization ID must be a valid UUID format. Got: '{organization_id}'.") + + # Fetch current secret to get existing values + current_secret = get_secret_for_update(client, secret_id, debug=debug) + + # Merge provided update fields with current values + # Only update fields that are explicitly provided + final_key = key if key is not None else current_secret["key"] + final_value = value if value is not None else current_secret["value"] + final_note = note if note is not None else current_secret["note"] + + # Handle project_ids + if project_ids is not None: + # Parse and validate project_ids + project_id_list = [pid.strip() for pid in project_ids.split(",") if pid.strip()] + if not project_id_list: + raise RuntimeError("INVALID_ID: Project IDs cannot be empty. Provide at least one valid UUID.") + + project_uuid_list = [] + for pid in project_id_list: + if not is_uuid(pid): + raise RuntimeError(f"INVALID_ID: Project ID must be a valid UUID format. Got: '{pid}'.") + try: + project_uuid_list.append(uuid.UUID(pid)) + except ValueError: + raise RuntimeError(f"INVALID_ID: Project ID must be a valid UUID format. Got: '{pid}'.") + else: + # Use current project_id if available + # Note: If secret was removed from project, project_id might be None + if current_secret.get("project_id"): + try: + project_uuid_list = [uuid.UUID(current_secret["project_id"])] + except (ValueError, TypeError): + # If current project_id is invalid, we need to provide one + raise RuntimeError("INVALID_ID: Current secret has no valid project_id. You must provide --project-ids when updating.") + else: + # Secret has no project_id (may have been removed from project) + # The SDK update() requires project_ids, so we must require it + raise RuntimeError("INVALID_ID: Current secret has no project_id (it may have been removed from its project). You must provide --project-ids when updating to assign it to a project.") + + if debug: + eprint(f"Updating secret id={secret_id} in organization id={organization_id}...") + eprint(f" Key: {final_key}") + if final_note: + eprint(f" Note: {final_note}") + eprint(f" Project IDs: {[str(p) for p in project_uuid_list]}") + + # Call SDK update method + update_response = client.secrets().update( + organization_id=org_uuid, + id=secret_id, + key=final_key, + value=final_value, + note=final_note, + project_ids=project_uuid_list, + ) + + if getattr(update_response, "success", False) and getattr(update_response, "data", None): + secret_data = update_response.data + updated_secret_id = getattr(secret_data, "id", None) + if updated_secret_id is None: + raise RuntimeError("SDK_ERROR: Secret updated but ID is empty/null") + return str(updated_secret_id) + + msg = getattr(update_response, "error_message", "Unknown error") + + # Check for 404 errors (secret not found, invalid org ID, or permission issues) + if "404" in msg or "not found" in msg.lower() or "Resource not found" in msg: + raise RuntimeError(f"NOT_FOUND: {msg}. This may indicate the secret doesn't exist, invalid organization ID, or insufficient permissions.") + + raise RuntimeError(f"SDK_ERROR: {msg}") + + +def build_parser(subcommand: str) -> argparse.ArgumentParser: + """Build argument parser for the given subcommand.""" + if subcommand == "get": + p = argparse.ArgumentParser( + description="Get a specific secret value from Bitwarden Secrets Manager (Bitwarden SDK)." + ) + p.add_argument("--secret-id", help="Secret ID (UUID format required) to fetch") + p.add_argument("--secret-name", help="Secret name/key to fetch (requires --org-id)") + p.add_argument( + "--access-token", + help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", + ) + p.add_argument( + "--org-id", + help="Organization ID (UUID) - required when using --secret-name, optional otherwise (prefer env var BWS_ORG_ID)", + ) + p.add_argument( + "--json", + action="store_true", + help="Print JSON output (includes secret_id, source). Value still included.", + ) + p.add_argument( + "--debug", + action="store_true", + help="Print debug logs to stderr.", + ) + elif subcommand == "create": + p = argparse.ArgumentParser( + description="Create a new secret in Bitwarden Secrets Manager (Bitwarden SDK)." + ) + p.add_argument( + "--key", + required=True, + help="Secret key/name (required)", + ) + p.add_argument( + "--value", + help="Secret value (optional, will read from stdin if not provided)", + ) + p.add_argument( + "--org-id", + required=True, + help="Organization ID (UUID format, required) (prefer env var BWS_ORG_ID)", + ) + p.add_argument( + "--note", + help="Note/description for the secret (optional)", + ) + p.add_argument( + "--project-ids", + dest="project_ids", + required=True, + help="Comma-separated list of project IDs (UUIDs, required). Secrets must be created within a project, not at the organization level.", + ) + p.add_argument( + "--access-token", + help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", + ) + p.add_argument( + "--json", + action="store_true", + help="Print JSON output (includes secret_id, key, org_id, etc.) instead of just secret ID.", + ) + p.add_argument( + "--allow-duplicate", + action="store_true", + help="Allow creating a secret even if one with the same key already exists in the project(s). By default, duplicate keys are not allowed.", + ) + p.add_argument( + "--debug", + action="store_true", + help="Print debug logs to stderr.", + ) + elif subcommand == "delete": + p = argparse.ArgumentParser( + description="Delete secret(s) from Bitwarden Secrets Manager (Bitwarden SDK)." + ) + p.add_argument( + "--secret-id", + action="append", + help="Secret ID (UUID format) to delete. Can be specified multiple times or comma-separated.", + ) + p.add_argument( + "--secret-name", + help="Secret name/key to delete (requires --org-id, only works if name is unique)", + ) + p.add_argument( + "--access-token", + help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", + ) + p.add_argument( + "--org-id", + help="Organization ID (UUID) - required when using --secret-name (prefer env var BWS_ORG_ID)", + ) + p.add_argument( + "--force", + action="store_true", + help="Skip confirmation prompt (required for non-interactive use)", + ) + p.add_argument( + "--json", + action="store_true", + help="Print JSON output (includes deleted_secret_ids, count) instead of just IDs.", + ) + p.add_argument( + "--debug", + action="store_true", + help="Print debug logs to stderr.", + ) + elif subcommand == "update": + p = argparse.ArgumentParser( + description="Update an existing secret in Bitwarden Secrets Manager (Bitwarden SDK)." + ) + p.add_argument( + "--secret-id", + help="Secret ID (UUID format) to update", + ) + p.add_argument( + "--secret-name", + help="Secret name/key to update (requires --org-id, only works if name is unique)", + ) + p.add_argument( + "--key", + help="New secret key/name (optional, only updates if provided)", + ) + p.add_argument( + "--value", + help="New secret value (optional, can read from stdin if not provided)", + ) + p.add_argument( + "--note", + help="New note/description (optional, only updates if provided)", + ) + p.add_argument( + "--project-ids", + dest="project_ids", + help="New project IDs (comma-separated list of UUIDs, optional, only updates if provided)", + ) + p.add_argument( + "--access-token", + help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", + ) + p.add_argument( + "--org-id", + help="Organization ID (UUID) - required when using --secret-name (prefer env var BWS_ORG_ID)", + ) + p.add_argument( + "--json", + action="store_true", + help="Print JSON output (includes secret_id, key, value, note, project_ids) instead of just secret ID.", + ) + p.add_argument( + "--force", + action="store_true", + help="Skip confirmation prompt when updating a secret that was removed from its project (requires --project-ids).", + ) + p.add_argument( + "--debug", + action="store_true", + help="Print debug logs to stderr.", + ) + elif subcommand == "list": + p = argparse.ArgumentParser( + description="List all secrets in Bitwarden Secrets Manager (Bitwarden SDK)." + ) + p.add_argument( + "--access-token", + help="Bitwarden Secrets Manager access token (prefer env var BWS_ACCESS_TOKEN)", + ) + p.add_argument( + "--org-id", + help="Organization ID (UUID, required) (prefer env var BWS_ORG_ID)", + ) + p.add_argument( + "--project-id", + help="Filter by project ID (UUID, optional). Note: Requires fetching each secret individually, which is slower.", + ) + p.add_argument( + "--key-pattern", + help="Filter by key name pattern (substring match, case-sensitive, optional)", + ) + p.add_argument( + "--json", + action="store_true", + help="Print JSON output (array of secret objects) instead of table format.", + ) + p.add_argument( + "--debug", + action="store_true", + help="Print debug logs to stderr.", + ) + else: + p = argparse.ArgumentParser( + description="Manage secrets in Bitwarden Secrets Manager (Bitwarden SDK)." + ) + return p + + +def handle_get(args: argparse.Namespace) -> int: + """Handle the 'get' subcommand.""" + # Validate that only one of --secret-id or --secret-name is provided + if args.secret_id and args.secret_name: + eprint("Error: Cannot specify both --secret-id and --secret-name. Use only one.") + return 2 + + access_token, secret_identifier, org_id, identifier_type, source = resolve_config(args) + + if not access_token or not secret_identifier: + eprint("Config error: missing Bitwarden credentials.") + eprint("Provide both access token and secret identifier via one of:") + eprint(" - CLI: --access-token ... --secret-id [--org-id ...]") + eprint(" - CLI: --access-token ... --secret-name --org-id ") + eprint(" - Env: BWS_ACCESS_TOKEN and BWS_SECRET_ID (or BWS_SECRET_NAME)") + eprint(" - Env: BWS_ORG_ID (required for --secret-name)") + eprint("") + # Debug info + if args.debug: + eprint("Debug info:") + eprint(f" CLI --access-token: {'provided' if args.access_token is not None else 'not provided'} ({'empty' if args.access_token == '' else 'has value'})") + eprint(f" CLI --secret-id: {'provided' if args.secret_id is not None else 'not provided'} ({'empty' if args.secret_id == '' else 'has value'})") + eprint(f" CLI --secret-name: {'provided' if args.secret_name is not None else 'not provided'} ({'empty' if args.secret_name == '' else 'has value'})") + eprint(f" Env BWS_ACCESS_TOKEN: {'set' if os.getenv('BWS_ACCESS_TOKEN') else 'not set'}") + eprint(f" Env BWS_SECRET_ID: {'set' if os.getenv('BWS_SECRET_ID') else 'not set'}") + eprint(f" Env BWS_SECRET_NAME: {'set' if os.getenv('BWS_SECRET_NAME') else 'not set'}") + return 2 + + try: + value = get_secret_value(access_token, secret_identifier, identifier_type, org_id=org_id, debug=args.debug) + + if args.json: + # Minimal JSON, no extra deps + org_json = f",\"org_id\":\"{org_id}\"" if org_id else "" + identifier_json = f"\"secret_{identifier_type}\":\"{secret_identifier}\"," + out = ( + "{" + f"{identifier_json}" + f"\"source\":\"{source}\"{org_json}," + f"\"value\":\"{value.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"" + "}" + ) + print(out) + else: + # value-only output for easy piping + print(value) + + return 0 + + except RuntimeError as exc: + msg = str(exc) + + if msg.startswith("AUTH_ERROR:"): + eprint(f"Error: Authentication failed. {msg[len('AUTH_ERROR: '):]}") + return 3 + + if msg.startswith("NOT_FOUND_OR_ERROR:") or msg.startswith("NOT_FOUND:"): + error_detail = msg[len('NOT_FOUND_OR_ERROR: '):] if msg.startswith('NOT_FOUND_OR_ERROR:') else msg[len('NOT_FOUND: '):] + eprint(f"Error: Secret not found. {error_detail}") + return 4 + + if msg.startswith("ORG_ID_REQUIRED:"): + eprint(f"Error: {msg[len('ORG_ID_REQUIRED: '):]}") + eprint("When using --secret-name, --org-id is required.") + return 2 + + if msg.startswith("INVALID_ID:"): + eprint(f"Error: {msg[len('INVALID_ID: '):]}") + return 2 + + if msg.startswith("LIST_ERROR:"): + eprint(f"Error: Failed to list secrets. {msg[len('LIST_ERROR: '):]}") + return 5 + + if msg.startswith("MULTIPLE_SECRETS:"): + eprint(f"Error: {msg[len('MULTIPLE_SECRETS: '):]}") + return 2 + + if msg.startswith("SDK_ERROR:"): + eprint(f"Error: SDK returned unexpected data. {msg[len('SDK_ERROR: '):]}") + return 5 + + eprint(f"Error: {msg}") + return 5 + + except Exception as exc: + eprint(f"Unexpected error: {exc}") + return 5 + + +def handle_create(args: argparse.Namespace) -> int: + """Handle the 'create' subcommand.""" + access_token, org_id, key, value, note, project_ids, source = resolve_create_config(args) + + # Validate required parameters + if not access_token: + eprint("Config error: missing access token.") + eprint("Provide access token via one of:") + eprint(" - CLI: --access-token ") + eprint(" - Env: BWS_ACCESS_TOKEN") + return 2 + + if not org_id: + eprint("Config error: missing organization ID.") + eprint("Provide organization ID via one of:") + eprint(" - CLI: --org-id ") + eprint(" - Env: BWS_ORG_ID") + return 2 + + if not key: + eprint("Config error: missing secret key.") + eprint("Provide secret key via:") + eprint(" - CLI: --key ") + return 2 + + if not project_ids: + eprint("Config error: missing project IDs.") + eprint("Secrets must be created within a project, not at the organization level.") + eprint("Provide at least one project ID via:") + eprint(" - CLI: --project-ids [,uuid2,...]") + eprint("Note: Multiple project IDs can be provided as a comma-separated list.") + return 2 + + # Handle value: CLI takes precedence, fallback to stdin + secret_value = value + if not secret_value: + try: + secret_value = read_value_from_stdin() + except RuntimeError as exc: + msg = str(exc) + if msg.startswith("VALUE_REQUIRED:"): + eprint(f"Error: {msg[len('VALUE_REQUIRED: '):]}") + return 2 + raise + + try: + secret_id = create_secret( + access_token=access_token, + organization_id=org_id, + key=key, + value=secret_value, + note=note, + project_ids=project_ids, + allow_duplicate=getattr(args, "allow_duplicate", False), + debug=args.debug, + ) + + if args.json: + # JSON output with all details + note_json = f",\"note\":\"{note.replace('\"', '\\\\\"')}\"" if note else "" + project_ids_json = "" + if project_ids: + project_id_list = [pid.strip() for pid in project_ids.split(",") if pid.strip()] + project_ids_array = ",".join([f'"{pid}"' for pid in project_id_list]) + project_ids_json = f",\"project_ids\":[{project_ids_array}]" + out = ( + "{" + f"\"secret_id\":\"{secret_id}\"," + f"\"key\":\"{key.replace('\"', '\\\\\"')}\"," + f"\"org_id\":\"{org_id}\"{note_json}{project_ids_json}" + "}" + ) + print(out) + else: + # secret ID only for easy piping + print(secret_id) + + return 0 + + except RuntimeError as exc: + msg = str(exc) + + if msg.startswith("AUTH_ERROR:"): + eprint(f"Error: Authentication failed. {msg[len('AUTH_ERROR: '):]}") + return 3 + + if msg.startswith("PROJECT_ID_REQUIRED:"): + eprint(f"Error: {msg[len('PROJECT_ID_REQUIRED: '):]}") + return 2 + + if msg.startswith("DUPLICATE_SECRET:"): + eprint(f"Error: {msg[len('DUPLICATE_SECRET: '):]}") + return 2 + + if msg.startswith("LIST_ERROR:"): + eprint(f"Error: Failed to check for duplicates. {msg[len('LIST_ERROR: '):]}") + return 5 + + if msg.startswith("INVALID_ID:"): + eprint(f"Error: {msg[len('INVALID_ID: '):]}") + return 2 + + if msg.startswith("NOT_FOUND:"): + error_detail = msg[len('NOT_FOUND: '):] + eprint(f"Error: Resource not found. {error_detail}") + eprint("This may indicate:") + eprint(" - Invalid organization ID or project ID") + eprint(" - Organization or project doesn't exist") + eprint(" - Access token doesn't have permission") + eprint(" - Note: Secrets must be created within a project, not at the organization level") + return 4 + + if msg.startswith("SDK_ERROR:"): + eprint(f"Error: SDK error. {msg[len('SDK_ERROR: '):]}") + return 5 + + eprint(f"Error: {msg}") + return 5 + + except Exception as exc: + # Check if it's a 404 error in the exception message + exc_msg = str(exc) + if "404" in exc_msg or "not found" in exc_msg.lower() or "Resource not found" in exc_msg: + eprint(f"Error: Resource not found. {exc_msg}") + eprint("This may indicate:") + eprint(" - Invalid organization ID or project ID") + eprint(" - Organization or project doesn't exist") + eprint(" - Access token doesn't have permission") + eprint(" - Note: Secrets must be created within a project, not at the organization level") + return 4 + eprint(f"Unexpected error: {exc}") + return 5 + + +def handle_update(args: argparse.Namespace) -> int: + """Handle the 'update' subcommand.""" + # Validate that only one of --secret-id or --secret-name is provided + if args.secret_id and args.secret_name: + eprint("Error: Cannot specify both --secret-id and --secret-name. Use only one.") + return 2 + + # Validate that at least one identifier is provided + if not args.secret_id and not args.secret_name: + eprint("Config error: missing secret identifier.") + eprint("Provide at least one secret identifier via:") + eprint(" - CLI: --secret-id ") + eprint(" - CLI: --secret-name --org-id ") + return 2 + + access_token, secret_id, secret_name, org_id, key, value, note, project_ids, source = resolve_update_config(args) + + # Validate required parameters + if not access_token: + eprint("Config error: missing access token.") + eprint("Provide access token via one of:") + eprint(" - CLI: --access-token ") + eprint(" - Env: BWS_ACCESS_TOKEN") + return 2 + + if secret_name and not org_id: + eprint("Config error: missing organization ID.") + eprint("Organization ID is required when using --secret-name.") + eprint("Provide organization ID via one of:") + eprint(" - CLI: --org-id ") + eprint(" - Env: BWS_ORG_ID") + return 2 + + # Validate that at least one update field is provided + if not key and not value and not note and not project_ids: + eprint("Config error: no update fields provided.") + eprint("Provide at least one field to update via:") + eprint(" - CLI: --key ") + eprint(" - CLI: --value (or pipe to stdin)") + eprint(" - CLI: --note ") + eprint(" - CLI: --project-ids [,uuid2,...]") + return 2 + + try: + # Resolve secret ID + resolved_secret_id = secret_id + resolved_org_id = org_id + + if secret_name: + # Resolve secret name to ID (only works if unique, like delete) + if not org_id: + raise RuntimeError("ORG_ID_REQUIRED: Organization ID is required when using --secret-name (provide via --org-id or BWS_ORG_ID environment variable)") + + debug_flag = getattr(args, "debug", False) + if debug_flag: + eprint(f"Resolving secret name '{secret_name}' to ID...") + + # For safety, only allow update by name if the name is unique + client = BitwardenClient() + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") + + try: + resolved_secret_id = find_secret_by_name(client, secret_name, org_id=org_id, debug=args.debug) + # Ensure it's a string (not UUID object) + resolved_secret_id = str(resolved_secret_id) if isinstance(resolved_secret_id, uuid.UUID) else resolved_secret_id + except RuntimeError as exc: + msg = str(exc) + if msg.startswith("MULTIPLE_SECRETS:"): + # For safety, don't allow update by name when duplicates exist + raise RuntimeError(f"MULTIPLE_SECRETS_UPDATE: Cannot update by name when multiple secrets share the same name. {msg[len('MULTIPLE_SECRETS: '):]}\n\nFor safety, use --secret-id to explicitly specify which secret to update.") + raise + + # Validate secret ID is present + if not resolved_secret_id: + eprint("Error: Could not resolve secret ID.") + return 2 + + # Get organization_id from current secret if not provided, and check if secret was removed from project + current_secret = None + if not resolved_org_id: + # Fetch current secret to get organization_id + client = BitwardenClient() + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") + + try: + current_secret = get_secret_for_update(client, resolved_secret_id, debug=getattr(args, "debug", False)) + resolved_org_id = current_secret["organization_id"] + if not resolved_org_id: + eprint("Error: Could not determine organization ID from secret. Please provide --org-id.") + return 2 + except RuntimeError as exc: + msg = str(exc) + if msg.startswith("NOT_FOUND:"): + # Secret doesn't exist or can't be accessed - provide helpful error message + eprint(f"Error: Secret not found. {msg[len('NOT_FOUND: '):]}") + eprint("This may indicate:") + eprint(" - The secret ID is incorrect") + eprint(" - The secret doesn't exist") + eprint(" - The secret was removed from its project and may need --org-id to be accessed") + eprint(" - You don't have permission to access this secret") + eprint("") + eprint("Try providing --org-id explicitly:") + eprint(" ./bwsm_secret.sh update --secret-id --org-id [update-fields] --access-token ") + return 4 + raise + else: + # Org ID provided, but we still need to fetch current secret to check if it was removed from project + client = BitwardenClient() + login_response = client.auth().login_access_token(access_token=access_token) + if not getattr(login_response, "success", False): + msg = getattr(login_response, "error_message", "Unknown authentication error") + raise RuntimeError(f"AUTH_ERROR: {msg}") + + try: + current_secret = get_secret_for_update(client, resolved_secret_id, debug=getattr(args, "debug", False)) + except RuntimeError as exc: + msg = str(exc) + if msg.startswith("NOT_FOUND:"): + eprint(f"Error: Secret not found. {msg[len('NOT_FOUND: '):]}") + eprint("This may indicate:") + eprint(" - The secret ID is incorrect") + eprint(" - The secret doesn't exist") + eprint(" - You don't have permission to access this secret") + return 4 + raise + + # Check if secret was removed from project (no project_id) + if current_secret and not current_secret.get("project_id"): + # Secret was removed from its project - warn user and require confirmation + eprint("Warning: This secret was removed from its project (has no project_id).") + eprint("Updating a secret without a project may fail due to Bitwarden API limitations.") + eprint("") + eprint("To update this secret, you must:") + eprint(" 1. Provide --project-ids to assign it to a project") + eprint(" 2. Confirm this operation (or use --force to skip confirmation)") + eprint("") + + # Require --project-ids if secret has no project_id + if not project_ids: + eprint("Error: Secret has no project_id. You must provide --project-ids to update it.") + eprint("Example:") + eprint(" ./bwsm_secret.sh update --secret-id --project-ids [other-fields] --force") + return 2 + + # Require confirmation (unless --force) + if not getattr(args, "force", False): + if sys.stdin.isatty(): + eprint("This will attempt to update a secret that was removed from its project.") + eprint("Continue? [y/N]: ", end="", flush=True) + try: + response = input().strip().lower() + if response not in ("y", "yes"): + eprint("Update cancelled.") + return 2 + except (EOFError, KeyboardInterrupt): + eprint("\nUpdate cancelled.") + return 2 + else: + # Non-interactive, require --force + eprint("Error: --force flag is required for non-interactive update of secrets removed from projects.") + eprint("This prevents accidental operations that may fail due to API limitations.") + return 2 + + # Handle value: CLI takes precedence, fallback to stdin + secret_value = value + if value is None: + # Check if we need to update value (if other fields are being updated, we might want to preserve value) + # But if user explicitly wants to update value, they should provide it + # For now, if value is None, we'll use current value (from get_secret_for_update in update_secret) + pass + elif not value: + # Empty string provided, try stdin + try: + secret_value = read_value_from_stdin() + except RuntimeError as exc: + msg = str(exc) + if msg.startswith("VALUE_REQUIRED:"): + # If stdin is empty, we'll use current value + secret_value = None + else: + raise + + # Update secret + updated_secret_id = update_secret( + access_token=access_token, + secret_id=resolved_secret_id, + organization_id=resolved_org_id, + key=key, + value=secret_value, + note=note, + project_ids=project_ids, + debug=getattr(args, "debug", False), + ) + + # Output + if args.json: + # Fetch updated secret to get all fields for JSON output + client = BitwardenClient() + login_response = client.auth().login_access_token(access_token=access_token) + if getattr(login_response, "success", False): + updated_secret = get_secret_for_update(client, updated_secret_id, debug=False) + note_json = f",\"note\":\"{updated_secret['note'].replace('\"', '\\\\\"')}\"" if updated_secret['note'] else "" + project_id_json = f",\"project_id\":\"{updated_secret['project_id']}\"" if updated_secret['project_id'] else "" + out = ( + "{" + f"\"secret_id\":\"{updated_secret_id}\"," + f"\"key\":\"{updated_secret['key'].replace('\"', '\\\\\"')}\"," + f"\"value\":\"{updated_secret['value'].replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"')}\"," + f"\"org_id\":\"{resolved_org_id}\"{note_json}{project_id_json}" + "}" + ) + print(out) + else: + # Fallback: just print secret ID + print(f'{{"secret_id":"{updated_secret_id}"}}') + else: + # secret ID only for easy piping + print(updated_secret_id) + + return 0 + + except RuntimeError as exc: + msg = str(exc) + + if msg.startswith("AUTH_ERROR:"): + eprint(f"Error: Authentication failed. {msg[len('AUTH_ERROR: '):]}") + return 3 + + if msg.startswith("ORG_ID_REQUIRED:"): + eprint(f"Error: {msg[len('ORG_ID_REQUIRED: '):]}") + eprint("When using --secret-name, --org-id is required.") + return 2 + + if msg.startswith("INVALID_ID:"): + eprint(f"Error: {msg[len('INVALID_ID: '):]}") + return 2 + + if msg.startswith("NOT_FOUND:"): + error_detail = msg[len('NOT_FOUND: '):] + eprint(f"Error: Secret not found. {error_detail}") + return 4 + + if msg.startswith("LIST_ERROR:"): + eprint(f"Error: Failed to list secrets. {msg[len('LIST_ERROR: '):]}") + return 5 + + if msg.startswith("MULTIPLE_SECRETS:") or msg.startswith("MULTIPLE_SECRETS_UPDATE:"): + error_msg = msg[len('MULTIPLE_SECRETS_UPDATE: '):] if msg.startswith('MULTIPLE_SECRETS_UPDATE:') else msg[len('MULTIPLE_SECRETS: '):] + eprint(f"Error: {error_msg}") + return 2 + + if msg.startswith("SDK_ERROR:"): + eprint(f"Error: SDK error. {msg[len('SDK_ERROR: '):]}") + return 5 + + eprint(f"Error: {msg}") + return 5 + + except Exception as exc: + # Check if it's a 404 error in the exception message + exc_msg = str(exc) + if "404" in exc_msg or "not found" in exc_msg.lower() or "Resource not found" in exc_msg: + eprint(f"Error: Secret not found. {exc_msg}") + return 4 + eprint(f"Unexpected error: {exc}") + return 5 + + +def handle_delete(args: argparse.Namespace) -> int: + """Handle the 'delete' subcommand.""" + # Validate that at least one identifier is provided + if not args.secret_id and not args.secret_name: + eprint("Config error: missing secret identifier.") + eprint("Provide at least one secret identifier via:") + eprint(" - CLI: --secret-id [--secret-id ...] (can specify multiple)") + eprint(" - CLI: --secret-name --org-id (only works if name is unique)") + eprint(" - Env: BWS_ACCESS_TOKEN and BWS_SECRET_ID (or BWS_SECRET_NAME)") + eprint(" - Env: BWS_ORG_ID (required for --secret-name)") + return 2 + + access_token, secret_ids, secret_name, org_id, source = resolve_delete_config(args) + + # Validate required parameters + if not access_token: + eprint("Config error: missing access token.") + eprint("Provide access token via one of:") + eprint(" - CLI: --access-token ") + eprint(" - Env: BWS_ACCESS_TOKEN") + return 2 + + if secret_name and not org_id: + eprint("Config error: missing organization ID.") + eprint("Organization ID is required when using --secret-name.") + eprint("Provide organization ID via one of:") + eprint(" - CLI: --org-id ") + eprint(" - Env: BWS_ORG_ID") + return 2 + + if not secret_ids and not secret_name: + eprint("Config error: missing secret identifier.") + eprint("Provide at least one secret identifier via:") + eprint(" - CLI: --secret-id [--secret-id ...]") + eprint(" - CLI: --secret-name --org-id ") + return 2 + + try: + # Resolve all secret identifiers to IDs + resolved_secret_ids = resolve_secret_ids( + access_token=access_token, + secret_ids=secret_ids, + secret_name=secret_name, + org_id=org_id, + debug=args.debug, + ) + + if not resolved_secret_ids: + eprint("Error: No secrets resolved for deletion.") + return 2 + + # Confirmation prompt (unless --force) + if not getattr(args, "force", False): + if sys.stdin.isatty(): + count = len(resolved_secret_ids) + secret_word = "secret" if count == 1 else "secrets" + eprint(f"Delete {count} {secret_word}? [y/N]: ", end="", flush=True) + try: + response = input().strip().lower() + if response not in ("y", "yes"): + eprint("Deletion cancelled.") + return 2 + except (EOFError, KeyboardInterrupt): + eprint("\nDeletion cancelled.") + return 2 + else: + # Non-interactive, require --force + eprint("Error: --force flag is required for non-interactive deletion.") + eprint("This prevents accidental deletions in scripts.") + return 2 + + # Delete secrets + # Ensure all IDs are strings (not UUID objects) + resolved_secret_ids_str = [str(sid) if isinstance(sid, uuid.UUID) else sid for sid in resolved_secret_ids] + + deleted_ids = delete_secret( + access_token=access_token, + secret_ids=resolved_secret_ids_str, + debug=args.debug, + ) + + # Output + if args.json: + # JSON output with deleted IDs and count + ids_array = ",".join([f'"{did}"' for did in deleted_ids]) + out = ( + "{" + f"\"deleted_secret_ids\":[{ids_array}]," + f"\"count\":{len(deleted_ids)}" + "}" + ) + print(out) + else: + # Print deleted IDs, one per line (for easy piping) + for did in deleted_ids: + print(did) + + return 0 + + except RuntimeError as exc: + msg = str(exc) + + if msg.startswith("AUTH_ERROR:"): + eprint(f"Error: Authentication failed. {msg[len('AUTH_ERROR: '):]}") + return 3 + + if msg.startswith("ORG_ID_REQUIRED:"): + eprint(f"Error: {msg[len('ORG_ID_REQUIRED: '):]}") + eprint("When using --secret-name, --org-id is required.") + return 2 + + if msg.startswith("INVALID_ID:"): + eprint(f"Error: {msg[len('INVALID_ID: '):]}") + return 2 + + if msg.startswith("NOT_FOUND:"): + error_detail = msg[len('NOT_FOUND: '):] + eprint(f"Error: Secret(s) not found. {error_detail}") + return 4 + + if msg.startswith("LIST_ERROR:"): + eprint(f"Error: Failed to list secrets. {msg[len('LIST_ERROR: '):]}") + return 5 + + if msg.startswith("MULTIPLE_SECRETS:") or msg.startswith("MULTIPLE_SECRETS_DELETE:"): + error_msg = msg[len('MULTIPLE_SECRETS_DELETE: '):] if msg.startswith('MULTIPLE_SECRETS_DELETE:') else msg[len('MULTIPLE_SECRETS: '):] + eprint(f"Error: {error_msg}") + return 2 + + if msg.startswith("SDK_ERROR:"): + eprint(f"Error: SDK error. {msg[len('SDK_ERROR: '):]}") + return 5 + + eprint(f"Error: {msg}") + return 5 + + except Exception as exc: + # Check if it's a 404 error in the exception message + exc_msg = str(exc) + if "404" in exc_msg or "not found" in exc_msg.lower() or "Resource not found" in exc_msg: + eprint(f"Error: Secret(s) not found. {exc_msg}") + return 4 + eprint(f"Unexpected error: {exc}") + return 5 + + +def handle_list(args: argparse.Namespace) -> int: + """Handle the 'list' subcommand.""" + access_token, org_id, project_id, key_pattern, source = resolve_list_config(args) + + # Validate required parameters + if not access_token: + eprint("Config error: missing access token.") + eprint("Provide access token via one of:") + eprint(" - CLI: --access-token ") + eprint(" - Env: BWS_ACCESS_TOKEN") + return 2 + + if not org_id: + eprint("Config error: missing organization ID.") + eprint("Provide organization ID via one of:") + eprint(" - CLI: --org-id ") + eprint(" - Env: BWS_ORG_ID") + return 2 + + try: + secrets = list_secrets( + access_token=access_token, + organization_id=org_id, + project_id=project_id, + key_pattern=key_pattern, + debug=args.debug, + ) + + # Format output + if args.json: + output = format_secrets_json(secrets) + print(output) + else: + output = format_secrets_table(secrets) + print(output) + + return 0 + + except RuntimeError as exc: + msg = str(exc) + + if msg.startswith("AUTH_ERROR:"): + eprint(f"Error: Authentication failed. {msg[len('AUTH_ERROR: '):]}") + return 3 + + if msg.startswith("LIST_ERROR:"): + eprint(f"Error: Failed to list secrets. {msg[len('LIST_ERROR: '):]}") + return 5 + + if msg.startswith("INVALID_ID:"): + eprint(f"Error: {msg[len('INVALID_ID: '):]}") + return 2 + + if msg.startswith("SDK_ERROR:"): + eprint(f"Error: SDK error. {msg[len('SDK_ERROR: '):]}") + return 5 + + eprint(f"Error: {msg}") + return 5 + + except Exception as exc: + eprint(f"Unexpected error: {exc}") + return 5 def main() -> int: diff --git a/scripts/bwsm_secret.sh b/scripts/bwsm_secret.sh index 8fc6b31..a63684f 100755 --- a/scripts/bwsm_secret.sh +++ b/scripts/bwsm_secret.sh @@ -21,10 +21,10 @@ usage() { echo "" echo "Subcommands:" echo " get - Get a secret value (default if no subcommand provided)" - echo " create - Create a new secret (coming soon)" - echo " update - Update an existing secret (coming soon)" - echo " delete - Delete secret(s) (coming soon)" - echo " list - List all secrets (coming soon)" + echo " create - Create a new secret" + echo " update - Update an existing secret" + echo " delete - Delete secret(s)" + echo " list - List all secrets" echo "" echo "For backward compatibility, if no subcommand is provided, 'get' is assumed." echo "Examples:" diff --git a/scripts/test_bwsm_secret.sh b/scripts/test_bwsm_secret.sh new file mode 100755 index 0000000..0b8c064 --- /dev/null +++ b/scripts/test_bwsm_secret.sh @@ -0,0 +1,400 @@ +#!/bin/bash + +# Test script for bwsm_secret.sh +# This script validates the main functionality of the Bitwarden Secrets Manager script + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test counters +TESTS_PASSED=0 +TESTS_FAILED=0 +TESTS_SKIPPED=0 + +# Script path +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/bwsm_secret.sh" + +# Check if required environment variables are set +if [[ -z "$BWS_ACCESS_TOKEN" ]] || [[ -z "$BWS_ORG_ID" ]] || [[ -z "$PROJECT_ID" ]]; then + echo -e "${YELLOW}Warning: Required environment variables not set.${NC}" + echo "Please set: BWS_ACCESS_TOKEN, BWS_ORG_ID, PROJECT_ID" + echo "" + echo "Some tests will be skipped." + SKIP_TESTS=true +else + SKIP_TESTS=false +fi + +# Test helper functions +test_pass() { + echo -e "${GREEN}✓ PASS${NC}: $1" + ((TESTS_PASSED++)) +} + +test_fail() { + echo -e "${RED}✗ FAIL${NC}: $1" + echo " Error: $2" + ((TESTS_FAILED++)) +} + +test_skip() { + echo -e "${YELLOW}⊘ SKIP${NC}: $1" + ((TESTS_SKIPPED++)) +} + +# Test function: Check if command succeeds +test_command() { + local test_name="$1" + local command="$2" + local expected_exit="${3:-0}" + + if [[ "$SKIP_TESTS" == "true" ]] && [[ "$test_name" == *"requires env"* ]]; then + test_skip "$test_name" + return + fi + + if eval "$command" >/tmp/test_output 2>&1; then + exit_code=$? + else + exit_code=$? + fi + + if [[ $exit_code -eq $expected_exit ]]; then + test_pass "$test_name" + else + test_fail "$test_name" "Expected exit code $expected_exit, got $exit_code" + if [[ -f /tmp/test_output ]]; then + echo " Output: $(head -3 /tmp/test_output | tr '\n' ' ')" + fi + fi +} + +# Test function: Check if output contains expected text +test_output_contains() { + local test_name="$1" + local command="$2" + local expected_text="$3" + + if [[ "$SKIP_TESTS" == "true" ]] && [[ "$test_name" == *"requires env"* ]]; then + test_skip "$test_name" + return + fi + + if output=$(eval "$command" 2>&1); then + if echo "$output" | grep -q "$expected_text"; then + test_pass "$test_name" + else + test_fail "$test_name" "Output does not contain '$expected_text'" + echo " Actual output: $output" + fi + else + test_fail "$test_name" "Command failed" + fi +} + +# Test function: Check if output does NOT contain text +test_output_not_contains() { + local test_name="$1" + local command="$2" + local unexpected_text="$3" + + if [[ "$SKIP_TESTS" == "true" ]] && [[ "$test_name" == *"requires env"* ]]; then + test_skip "$test_name" + return + fi + + if output=$(eval "$command" 2>&1); then + if ! echo "$output" | grep -q "$unexpected_text"; then + test_pass "$test_name" + else + test_fail "$test_name" "Output contains unexpected '$unexpected_text'" + fi + else + test_fail "$test_name" "Command failed" + fi +} + +echo "==========================================" +echo "Testing bwsm_secret.sh" +echo "==========================================" +echo "" + +# Test 1: Script exists and is executable +echo "=== Basic Tests ===" +test_command "Script exists and is executable" "test -x '$SCRIPT'" + +# Test 2: Help/usage works +test_command "Help/usage displays" "'$SCRIPT' --help" 1 + +# Test 3: Invalid subcommand +test_command "Invalid subcommand returns error" "'$SCRIPT' invalid-subcommand" 2 + +# Test 4: Missing required arguments (get) +echo "" +echo "=== Get Subcommand Tests ===" +test_command "Get without arguments returns error" "'$SCRIPT' get" 2 + +# Test 5: Missing access token +test_command "Get without access token returns error" "'$SCRIPT' get --secret-id 00000000-0000-0000-0000-000000000000" 2 + +# Test 6: Invalid secret ID format +if [[ "$SKIP_TESTS" == "false" ]]; then + test_command "Get with invalid secret ID format returns error" "'$SCRIPT' get --secret-id invalid-uuid --access-token '$BWS_ACCESS_TOKEN'" 2 +fi + +# Test 7: Get with secret-name requires org-id +test_command "Get with secret-name without org-id returns error" "'$SCRIPT' get --secret-name test --access-token test-token" 2 + +# Test 8: Create subcommand tests +echo "" +echo "=== Create Subcommand Tests ===" +test_command "Create without arguments returns error" "'$SCRIPT' create" 2 + +# Test 9: Create missing required arguments +test_command "Create without key returns error" "'$SCRIPT' create --org-id test --project-ids test --access-token test" 2 + +# Test 10: Create without org-id +test_command "Create without org-id returns error" "'$SCRIPT' create --key test-key --project-ids test --access-token test" 2 + +# Test 11: Create without project-ids +test_command "Create without project-ids returns error" "'$SCRIPT' create --key test-key --org-id test --access-token test" 2 + +# Test 12: Create with invalid UUID format +if [[ "$SKIP_TESTS" == "false" ]]; then + test_command "Create with invalid org-id UUID returns error" "'$SCRIPT' create --key test --org-id invalid-uuid --project-ids '$PROJECT_ID' --access-token '$BWS_ACCESS_TOKEN' --value test" 2 +fi + +# Test 13: Duplicate detection (if env vars are set) +if [[ "$SKIP_TESTS" == "false" ]]; then + echo "" + echo "=== Duplicate Detection Tests ===" + + # Create a test secret first + TEST_KEY="test-$(date +%s)" + echo "Creating test secret: $TEST_KEY" + + if SECRET_ID=$("$SCRIPT" create --key "$TEST_KEY" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" --value "test-value" 2>&1); then + test_pass "Create test secret for duplicate test" + + # Try to create duplicate (should fail) + test_command "Create duplicate secret without --allow-duplicate fails" "'$SCRIPT' create --key '$TEST_KEY' --org-id '$BWS_ORG_ID' --project-ids '$PROJECT_ID' --access-token '$BWS_ACCESS_TOKEN' --value 'test-value2'" 2 + + # Try to create duplicate with --allow-duplicate (should succeed) + test_command "Create duplicate secret with --allow-duplicate succeeds" "'$SCRIPT' create --key '$TEST_KEY' --org-id '$BWS_ORG_ID' --project-ids '$PROJECT_ID' --access-token '$BWS_ACCESS_TOKEN' --value 'test-value3' --allow-duplicate" 0 + + # Clean up: Get the secret IDs and note them for manual cleanup + echo " Note: Test secrets created with key '$TEST_KEY' - please clean up manually" + else + test_skip "Create test secret for duplicate test (requires env vars)" + test_skip "Duplicate detection test" + fi +fi + +# Test 14: Multiple secrets with same name handling +if [[ "$SKIP_TESTS" == "false" ]]; then + echo "" + echo "=== Multiple Secrets Tests ===" + + # This test assumes there are already multiple secrets with the same name + # We'll test that the error message is helpful + test_output_contains "Get with secret-name shows helpful error when multiple exist" \ + "'$SCRIPT' get --secret-name 'my-secret' --org-id '$BWS_ORG_ID' --access-token '$BWS_ACCESS_TOKEN' 2>&1" \ + "MULTIPLE_SECRETS\|To disambiguate" + +fi + +# Test 15: JSON output format +if [[ "$SKIP_TESTS" == "false" ]]; then + echo "" + echo "=== Output Format Tests ===" + + # Test JSON output contains expected fields + test_output_contains "Create with --json outputs JSON" \ + "'$SCRIPT' create --key 'json-test-$(date +%s)' --org-id '$BWS_ORG_ID' --project-ids '$PROJECT_ID' --access-token '$BWS_ACCESS_TOKEN' --value 'test' --json" \ + "secret_id" +fi + +# Test 16: Stdin input +if [[ "$SKIP_TESTS" == "false" ]]; then + echo "" + echo "=== Stdin Input Tests ===" + + TEST_KEY="stdin-test-$(date +%s)" + test_command "Create with value from stdin" \ + "echo 'stdin-value' | '$SCRIPT' create --key '$TEST_KEY' --org-id '$BWS_ORG_ID' --project-ids '$PROJECT_ID' --access-token '$BWS_ACCESS_TOKEN'" \ + "0" +fi + +# Test 17: Debug mode +if [[ "$SKIP_TESTS" == "false" ]]; then + echo "" + echo "=== Debug Mode Tests ===" + + test_output_contains "Create with --debug shows debug output" \ + "'$SCRIPT' create --key 'debug-test-$(date +%s)' --org-id '$BWS_ORG_ID' --project-ids '$PROJECT_ID' --access-token '$BWS_ACCESS_TOKEN' --value 'test' --debug 2>&1" \ + "Authenticating\|Creating" +fi + +# Test 18: Update subcommand tests +echo "" +echo "=== Update Subcommand Tests ===" +test_command "Update without arguments returns error" "'$SCRIPT' update" 2 + +# Test 19: Update missing required arguments +test_command "Update without secret identifier returns error" "'$SCRIPT' update --key test --access-token test" 2 + +# Test 20: Update with no update fields +if [[ "$SKIP_TESTS" == "false" ]] && [[ -n "$SECRET_ID" ]]; then + test_output_contains "Update without update fields shows error" \ + "'$SCRIPT' update --secret-id '$SECRET_ID' --access-token '$BWS_ACCESS_TOKEN' 2>&1" \ + "no update fields provided" +else + test_skip "Update without update fields test (requires env vars)" +fi + +# Test 21: Update with invalid UUID +test_command "Update with invalid secret ID format returns error" "'$SCRIPT' update --secret-id invalid-uuid --key test --access-token test" 2 + +# Test 22: Update by secret-name requires org-id +test_command "Update with secret-name without org-id returns error" "'$SCRIPT' update --secret-name test --key test --access-token test" 2 + +# Test 23: Update both --secret-id and --secret-name +test_command "Update with both --secret-id and --secret-name returns error" "'$SCRIPT' update --secret-id test --secret-name test --key test --access-token test" 2 + +# Test 24: Update by secret-id (value only) +if [[ "$SKIP_TESTS" == "false" ]] && [[ -n "$SECRET_ID" ]]; then + OLD_VALUE=$("$SCRIPT" get --secret-id "$SECRET_ID" --access-token "$BWS_ACCESS_TOKEN" 2>/dev/null || echo "") + NEW_VALUE="updated-value-$(date +%s)" + if OUTPUT=$("$SCRIPT" update --secret-id "$SECRET_ID" --value "$NEW_VALUE" --access-token "$BWS_ACCESS_TOKEN" 2>&1) && \ + UPDATED_VALUE=$("$SCRIPT" get --secret-id "$SECRET_ID" --access-token "$BWS_ACCESS_TOKEN" 2>/dev/null || echo "") && \ + [[ "$UPDATED_VALUE" == "$NEW_VALUE" ]]; then + test_pass "Update by secret-id (value only)" + # Restore old value + "$SCRIPT" update --secret-id "$SECRET_ID" --value "$OLD_VALUE" --access-token "$BWS_ACCESS_TOKEN" >/dev/null 2>&1 || true + else + test_fail "Update by secret-id (value only)" "Value mismatch or update failed" + fi +else + test_skip "Update by secret-id (value only) (requires env vars)" +fi + +# Test 25: Update with --json flag +if [[ "$SKIP_TESTS" == "false" ]] && [[ -n "$SECRET_ID" ]]; then + test_output_contains "Update with --json outputs JSON" \ + "'$SCRIPT' update --secret-id '$SECRET_ID' --note 'Test note $(date +%s)' --access-token '$BWS_ACCESS_TOKEN' --json" \ + "secret_id" +else + test_skip "Update with --json flag (requires env vars)" +fi + +# Test 26: Update with value from stdin +if [[ "$SKIP_TESTS" == "false" ]] && [[ -n "$SECRET_ID" ]]; then + OLD_VALUE=$("$SCRIPT" get --secret-id "$SECRET_ID" --access-token "$BWS_ACCESS_TOKEN" 2>/dev/null || echo "") + NEW_VALUE="stdin-value-$(date +%s)" + if OUTPUT=$(echo "$NEW_VALUE" | "$SCRIPT" update --secret-id "$SECRET_ID" --access-token "$BWS_ACCESS_TOKEN" 2>&1) && \ + UPDATED_VALUE=$("$SCRIPT" get --secret-id "$SECRET_ID" --access-token "$BWS_ACCESS_TOKEN" 2>/dev/null || echo "") && \ + [[ "$UPDATED_VALUE" == "$NEW_VALUE" ]]; then + test_pass "Update with value from stdin" + # Restore old value + "$SCRIPT" update --secret-id "$SECRET_ID" --value "$OLD_VALUE" --access-token "$BWS_ACCESS_TOKEN" >/dev/null 2>&1 || true + else + test_fail "Update with value from stdin" "Value mismatch or update failed" + fi +else + test_skip "Update with value from stdin (requires env vars)" +fi + +# Test 27: Update error - secret not found +if [[ "$SKIP_TESTS" == "false" ]]; then + test_command "Update with non-existent secret ID returns error" "'$SCRIPT' update --secret-id '00000000-0000-0000-0000-000000000000' --key 'new-key' --access-token '$BWS_ACCESS_TOKEN'" 4 +else + test_skip "Update error - secret not found (requires env vars)" +fi + +# Test 28: Delete subcommand tests +echo "" +echo "=== Delete Subcommand Tests ===" +test_command "Delete without arguments returns error" "'$SCRIPT' delete" 2 + +# Test 29: Delete missing required arguments +test_command "Delete without secret identifier returns error" "'$SCRIPT' delete --access-token test" 2 + +# Test 30: Delete missing force flag (non-interactive) +if [[ "$SKIP_TESTS" == "false" ]]; then + test_output_contains "Delete without --force in non-interactive mode shows error" \ + "echo 'test' | '$SCRIPT' delete --secret-id '00000000-0000-0000-0000-000000000000' --access-token '$BWS_ACCESS_TOKEN' 2>&1" \ + "--force flag is required" +fi + +# Test 31: Delete with invalid UUID +if [[ "$SKIP_TESTS" == "false" ]]; then + test_command "Delete with invalid secret ID format returns error" "'$SCRIPT' delete --secret-id invalid-uuid --access-token '$BWS_ACCESS_TOKEN' --force" 2 +fi + +# Test 32: Delete by secret-name requires org-id +test_command "Delete with secret-name without org-id returns error" "'$SCRIPT' delete --secret-name test --access-token test --force" 2 + +# Test 33: Delete multiple secrets +if [[ "$SKIP_TESTS" == "false" ]]; then + # Create test secrets first + TEST_KEY1="delete-test-1-$(date +%s)" + TEST_KEY2="delete-test-2-$(date +%s)" + echo "Creating test secrets for deletion test..." + + if SECRET_ID1=$("$SCRIPT" create --key "$TEST_KEY1" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" --value "test-value1" 2>&1) && \ + SECRET_ID2=$("$SCRIPT" create --key "$TEST_KEY2" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" --value "test-value2" 2>&1); then + test_pass "Create test secrets for deletion test" + + # Test delete by ID + test_command "Delete single secret by ID" \ + "'$SCRIPT' delete --secret-id '$SECRET_ID1' --access-token '$BWS_ACCESS_TOKEN' --force" \ + "0" + + # Test delete multiple secrets + test_command "Delete multiple secrets by ID" \ + "'$SCRIPT' delete --secret-id '$SECRET_ID2' --access-token '$BWS_ACCESS_TOKEN' --force" \ + "0" + + echo " Note: Test secrets deleted - cleanup complete" + else + test_skip "Create test secrets for deletion test (requires env vars)" + test_skip "Delete by ID test" + test_skip "Delete multiple secrets test" + fi +fi + +# Test 34: Delete JSON output +if [[ "$SKIP_TESTS" == "false" ]]; then + TEST_KEY="json-delete-test-$(date +%s)" + if SECRET_ID=$("$SCRIPT" create --key "$TEST_KEY" --org-id "$BWS_ORG_ID" --project-ids "$PROJECT_ID" --access-token "$BWS_ACCESS_TOKEN" --value "test" 2>&1); then + test_output_contains "Delete with --json outputs JSON" \ + "'$SCRIPT' delete --secret-id '$SECRET_ID' --access-token '$BWS_ACCESS_TOKEN' --force --json" \ + "deleted_secret_ids" + else + test_skip "Delete JSON output test (requires env vars)" + fi +fi + +# Summary +echo "" +echo "==========================================" +echo "Test Summary" +echo "==========================================" +echo -e "${GREEN}Passed: $TESTS_PASSED${NC}" +echo -e "${RED}Failed: $TESTS_FAILED${NC}" +echo -e "${YELLOW}Skipped: $TESTS_SKIPPED${NC}" +echo "" + +if [[ $TESTS_FAILED -eq 0 ]]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed.${NC}" + exit 1 +fi