diff --git a/.gitignore b/.gitignore index b6f6907..a813029 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ TRASH-FILES.md PRD-*.md TODOS.md bcapi_cli_prd.md +.planning/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1346904..1ffb6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5] — 2026-05-05 + ### Added +- **Business Central admin setup guide** — new + `docs/business-central-admin-setup.md` walks a zero-knowledge user + through Entra app registration, localhost redirect setup, delegated BC + permissions, admin consent, BC user permission sets, first `bcli + config init`, and verification. - **`bcli-mcp` preview server** — an MCP (Model Context Protocol) server that lets Claude Desktop and other MCP clients drive bcli. Four read-only tools: `query`, `list_endpoints`, `describe_endpoint`, @@ -18,12 +25,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `bcli config init` now defaults to browser PKCE auth for local humans + and agents. New `--automation` and `--headless` shortcuts create + client-credentials and device-code profiles respectively. +- CLI runtime dependencies now ship with the base `bc-cli` install, so + `pip install bc-cli` and `uv tool install bc-cli` provide a working + `bcli` command without requiring an extra. - `bcli company list` accepts `--format` (`json`, `markdown`, `csv`, `ndjson`, `table`). Stable JSON shape: `[{"id", "name", "alias", "is_default"}]`. - `bcli endpoint list` and `bcli endpoint info` accept `--format json`. Stable JSON shapes documented inline in each command's help text. +### Removed + +- Removed WorkOS AuthKit support. Browser PKCE is now the delegated auth + path, Business Central remains the permission boundary, and + client-credentials profiles cover automation. + ## [0.1.2] — 2026-04-29 Security release. Closes four findings from a strix.ai run against the diff --git a/README.md b/README.md index 6e778c1..b8f434b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)](pyproject.toml) -A Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs, with a built-in [dlt](https://dlthub.com) source for ETL backup pipelines. +A Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs, with +agent-friendly endpoint discovery, browser auth, custom API registries, and a +built-in [dlt](https://dlthub.com) source for ETL backup pipelines. > **Status: Alpha (0.1.x).** Public surface may change before 1.0. Track > [CHANGELOG.md](CHANGELOG.md) for breaking changes. Independent project @@ -50,7 +52,7 @@ pip install bc-cli # or uv tool install bc-cli -# Configure (interactive — discovers companies automatically) +# Configure with browser auth (no client secret) bcli config init # Query standard APIs immediately @@ -73,7 +75,7 @@ bcli get myCustomEntities --top 5 - **Multi-company** — Assign aliases to companies and query across all entities - **OData query builder** — `--filter`, `--select`, `--expand`, `--orderby`, `--top`, `--skip` on every query - **Multiple output formats** — table, JSON, CSV, NDJSON for pipeline use -- **Secure auth** — OS keychain integration (macOS Keychain, Windows Credential Manager), token caching, client credentials + device code flows +- **Secure auth** — Browser PKCE by default, OS keychain support for automation secrets, token caching, client credentials + device code fallback - **Write safety** — SafeContext gate prevents wrong-environment writes, enforces draft status on financial documents - **Programmatic auth** — Pass credentials directly for MCP servers, Airflow DAGs, and containers (no config files required) - **Batch operations** — Execute sequences of API calls from YAML files @@ -134,17 +136,14 @@ Requires Python 3.11+. > documented. ```bash -# SDK only (for libraries, MCP servers, Airflow DAGs) +# CLI + SDK pip install bc-cli -# SDK + CLI -pip install "bc-cli[cli]" - # SDK + ETL (dlt source for backup pipelines) pip install "bc-cli[etl]" # Everything -pip install "bc-cli[cli,etl]" +pip install "bc-cli[etl,mcp,telemetry]" # Via uv (recommended) uv tool install bc-cli @@ -160,8 +159,9 @@ pip install -e ".[dev,etl]" | Guide | Description | |-------|-------------| | [Getting Started](docs/getting-started.md) | First-time setup, authentication, your first query | +| [Business Central Admin Setup](docs/business-central-admin-setup.md) | Entra app registration and BC permissions from scratch | | [Configuration](docs/configuration.md) | Profiles, environments, config file format | -| [Authentication](docs/authentication.md) | Client credentials, device code, OS keychain | +| [Authentication](docs/authentication.md) | Browser auth, client credentials, device code fallback | | [Querying Data](docs/querying.md) | GET, OData filters, pagination, output formats | | [Write Operations](docs/write-operations.md) | POST, PATCH, DELETE | | [Custom APIs](docs/custom-apis.md) | Importing from Postman, JSON, or $metadata | diff --git a/docs/authentication.md b/docs/authentication.md index 25cdaba..7e6b38a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,194 +1,104 @@ # Authentication -bcli supports four authentication methods for Business Central online: +bcli supports three Business Central online authentication methods: | Method | Flow | Use case | |--------|------|----------| -| `client_credentials` | App permissions (service-to-service) | CI/CD, MCP servers, background jobs | -| `browser` | Authorization code with PKCE (delegated) | Interactive dev, individual user permissions | -| `device_code` | Device code (delegated) | Headless hosts, SSH sessions, non-browser envs | -| `workos` | WorkOS AuthKit SSO → role-based BC client | Teams with role-based access control | +| `browser` | Authorization code with PKCE | Default for local humans and AI agents | +| `client_credentials` | App permissions | CI/CD, servers, scheduled jobs | +| `device_code` | Delegated device code | SSH/headless fallback | -Select a method per profile via `auth_method` or override per call with `bcli auth login --method `. +For a full zero-knowledge Entra ID and Business Central setup, start with +[Business Central Admin Setup](business-central-admin-setup.md). -## Client Credentials (Service-to-Service) +## Browser Auth (Recommended) -The default method. Uses an Azure Entra ID app registration with application permissions. - -### Prerequisites - -1. An app registration in Azure Entra ID -2. `API.ReadWrite.All` application permission granted for Dynamics 365 Business Central -3. A client secret generated for the app registration - -### Setup +Browser auth is the default for `bcli config init`. It opens the user's browser, +uses PKCE, needs no client secret, and Business Central enforces the signed-in +user's permission sets. ```bash bcli config init -# Provide tenant ID, client ID, and the name of the env var holding your secret -``` - -### Secret Storage - -bcli never stores secrets in config files. Instead, it resolves secrets at runtime in this order: - -1. **OS Keychain** (recommended) — macOS Keychain, Windows Credential Manager -2. **Environment variable** — Referenced by name in `client_secret_env` -3. **Generic fallback** — `BCLI_SECRET` or `BCLI_CLIENT_SECRET` env vars - -#### Store in Keychain (Recommended) - -```bash -bcli auth store-secret -# Prompted for the secret (hidden input) -``` - -This is the best option because: -- The secret persists across shell sessions -- No env var to manage -- Works with any tool that runs bcli (Claude Code, scripts, cron) -- Secured by OS-level encryption - -#### Store as Environment Variable - -```bash -# In your shell profile (~/.zshrc or ~/.bashrc) -export BCLI_SECRET="your-secret-here" -``` - -### Token Caching - -After authentication, bcli caches the access token at `~/.config/bcli/tokens.json`. Tokens are reused until 5 minutes before expiry (~55 minutes for BC tokens). While a cached token is valid, no secret is needed. - -```bash -# Check token status -bcli auth status - -# Force re-authentication -bcli auth logout bcli auth login +bcli get customers --top 5 ``` -## Browser (Authorization Code with PKCE) +The Entra app registration must be a public/native client with delegated +Business Central permissions and a localhost redirect URI. See +[Business Central Admin Setup](business-central-admin-setup.md) for the portal +steps. -Interactive browser-based OAuth. The user authenticates in their default browser; bcli captures the redirect via a local loopback on port 8400. Uses PKCE — no client secret needed, and delegated permissions mean the token carries the *user's* BC permissions, not app-wide ones. - -### Setup +Useful login options: ```bash -bcli config set profiles.interactive.auth_method browser -bcli config set profiles.interactive.tenant_id "your-tenant-id" -bcli config set profiles.interactive.client_id "your-client-id" -bcli config set profiles.interactive.environment "Production" -``` - -The app registration must: -- Have delegated permissions for Business Central (`user_impersonation` / `Financials.ReadWrite.All`) -- Include `http://localhost:8400/callback` as a redirect URI -- Be configured as a public client (no client secret required) - -### Usage +# Fresh browser session, useful when switching accounts +bcli auth login --method browser --incognito -```bash -bcli -p interactive auth login --method browser - -# Fresh session (no cached browser login) — useful for switching accounts: -bcli -p interactive auth login --method browser --incognito +# Explicit browser profile setup +bcli config init --auth browser ``` -If a `login_hint` is set in your profile (e.g. via WorkOS), the BC account picker is skipped automatically. - -## Device Code (Interactive) +## Client Credentials -For interactive use where a user is present but a browser redirect is not practical (SSH, headless hosts). The user authenticates via a browser on any device — no client secret needed. - -### Setup - -Set `auth_method = "device_code"` in your profile: - -```bash -bcli config set profiles.interactive.auth_method device_code -bcli config set profiles.interactive.tenant_id "your-tenant-id" -bcli config set profiles.interactive.client_id "your-client-id" -bcli config set profiles.interactive.environment "Production" -``` - -### Usage +Use client credentials only for automation: CI/CD, background jobs, servers, and +scheduled exports. This path uses application permissions and a client secret, +so it should be set up deliberately. ```bash -bcli -p interactive auth login --method device -# Prints a URL and code — open the URL in your browser and enter the code +bcli config init --automation +bcli auth store-secret +bcli get customers --top 5 ``` -The app registration must have delegated permissions (not application permissions) for device code flow to work. - -## WorkOS AuthKit (Role-Based BC Access) +bcli never stores client secrets in config files. It resolves secrets in this +order: -Two-step auth: users authenticate via WorkOS SSO, then their WorkOS group determines which BC client_id they use for the BC browser flow. Useful for teams where different roles need different BC app registrations (e.g. `finance-read-only` vs `finance-write`). +1. OS keychain: macOS Keychain, Windows Credential Manager, or equivalent. +2. The configured `client_secret_env` environment variable. +3. Generic fallback env vars: `BCLI_SECRET` or `BCLI_CLIENT_SECRET`. -### Flow +In CI, use environment variables: -1. User authenticates via WorkOS AuthKit (browser redirect) -2. bcli looks up the user's WorkOS group membership -3. bcli maps the group to a BC `client_id` via the profile's `workos.groups` table -4. BC browser OAuth runs with the resolved `client_id` (PKCE, delegated) - -The WorkOS identity is cached at `~/.config/bcli/workos_identity.json` until it expires. +```yaml +env: + BCLI_SECRET: ${{ secrets.BC_CLIENT_SECRET }} -### Setup +steps: + - run: bcli get customers --top 1 -f json -q +``` -```toml -[profiles.example] -tenant_id = "c6aabf12-..." -environment = "Production" -auth_method = "workos" +No `bcli auth login` is required when a valid secret is available; bcli acquires +tokens automatically. -[profiles.example.workos] -api_key_env = "WORKOS_API_KEY" -client_id = "client_01ABC..." +## Device Code -[profiles.example.workos.groups] -"finance-read" = "48074c7f-..." # BC app registration for read-only finance role -"finance-write" = "9a12d8e3-..." # BC app registration for write finance role -"ops-full" = "bf441e7a-..." -``` - -Install the `cli` extra (WorkOS SDK is included): +Device code is a fallback for hosts where a localhost browser callback is not +practical, such as SSH sessions or locked-down remote machines. ```bash -pip install "bc-cli[cli]" +bcli config init --headless +bcli auth login --method device ``` -### Usage - -```bash -bcli -p example auth login --method workos +The terminal prints a URL and code. Open the URL in any browser, enter the code, +and bcli caches the resulting delegated token. -# Switch to a different user without touching OS browser sessions: -bcli -p example auth login --method workos --incognito -``` +## Token Cache -## Auth Commands +After authentication, bcli caches access tokens at +`~/.config/bcli/tokens.json`. Tokens are reused until shortly before expiry. ```bash -bcli auth login [--method ...] [-i] # Authenticate and cache token (see command-reference.md) -bcli auth status # Show token and keychain status -bcli auth logout # Clear cached tokens -bcli auth store-secret # Store secret in OS keychain -bcli auth delete-secret # Remove secret from OS keychain +bcli auth status +bcli auth logout ``` -## CI/CD Usage - -For CI/CD pipelines, use environment variables: - -```yaml -# GitHub Actions example -env: - BCLI_SECRET: ${{ secrets.BC_CLIENT_SECRET }} - -steps: - - run: bcli get customers --top 1 -f json -q -``` +## Common Failures -No `bcli auth login` is needed — bcli acquires tokens automatically when a secret is available. +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Redirect URI mismatch | Entra app lacks localhost redirect URI | Add `http://localhost` as a mobile/desktop redirect URI | +| Consent required | Tenant admin has not granted API consent | Grant admin consent for Business Central delegated permissions | +| 403 Forbidden | User lacks BC permission set for that page/company | Assign the user the required Business Central permissions | +| Wrong tenant/account | Browser reused an existing Microsoft session | Re-run with `--incognito` | +| Secret missing | Automation profile cannot find `BCLI_SECRET` or keychain secret | Run `bcli auth store-secret` or set the env var | diff --git a/docs/business-central-admin-setup.md b/docs/business-central-admin-setup.md new file mode 100644 index 0000000..23a3a03 --- /dev/null +++ b/docs/business-central-admin-setup.md @@ -0,0 +1,121 @@ +# Business Central Admin Setup + +This guide is for users who do not already have an Entra app registration or +Business Central API permissions ready for bcli. + +## Recommended Local Setup: Browser Auth + +Use this path for humans and local AI agents. It needs no client secret. + +### 1. Create An Entra App Registration + +1. Open the Microsoft Entra admin center. +2. Go to **Identity** -> **Applications** -> **App registrations**. +3. Select **New registration**. +4. Name it something clear, such as `bcli-local`. +5. Choose the supported account type for your tenant. +6. Register the app. +7. Copy the **Application (client) ID** and **Directory (tenant) ID**. + +### 2. Configure Browser Redirect + +1. Open the app registration. +2. Go to **Authentication**. +3. Add a platform: **Mobile and desktop applications**. +4. Add redirect URI: `http://localhost`. +5. Save. + +bcli binds an available localhost port at login time. Entra accepts localhost +redirects for native clients without requiring one fixed port. + +### 3. Add Business Central API Permission + +1. Open **API permissions**. +2. Select **Add a permission**. +3. Choose **Dynamics 365 Business Central**. +4. Choose **Delegated permissions**. +5. Add the Business Central delegated permission your tenant requires, commonly + `user_impersonation` or `Financials.ReadWrite.All`. +6. Grant admin consent if your tenant requires it. + +### 4. Assign Business Central Permissions + +The browser token carries the signed-in user. Business Central still decides +what that user can see or change. + +1. Open Business Central. +2. Search for **Users**. +3. Open the user who will run bcli. +4. Assign the required permission sets for the companies and pages they need. +5. Start read-only when possible, then add write permissions deliberately. + +### 5. Configure bcli + +```bash +bcli config init +``` + +Use: + +- Tenant ID: the Directory tenant ID from Entra. +- Environment: `Production`, `Sandbox`, or your BC environment name. +- Client ID: the Application client ID from Entra. +- Auth method: browser auth is the default. + +When bcli asks to authenticate, accept. It opens a browser, completes Microsoft +sign-in, discovers companies, and lets you choose a default company. + +### 6. Verify + +```bash +bcli test connection +bcli get customers --top 5 +``` + +If this fails with `403 Forbidden`, authentication worked but Business Central +permissions are missing for that user. + +## Automation Setup: Client Credentials + +Use this path for CI/CD, servers, and scheduled jobs. + +### 1. Create Or Reuse A Confidential App + +Create an Entra app registration for automation and add Business Central +**application** permissions. Generate a client secret or certificate according +to your organization's policy. + +### 2. Grant Consent And BC Access + +Grant admin consent for the application permission. In Business Central, ensure +the application identity has the required API access and permission sets for the +target companies. + +### 3. Configure bcli + +```bash +bcli config init --automation +bcli auth store-secret +``` + +For CI, store the secret in your pipeline secret manager and expose it as +`BCLI_SECRET` or the `client_secret_env` name you chose during setup. + +## Headless Fallback: Device Code + +Use device code only when browser callback auth cannot work, such as SSH hosts. + +```bash +bcli config init --headless +bcli auth login --method device +``` + +## Troubleshooting + +| Error | Meaning | Fix | +|-------|---------|-----| +| Redirect URI mismatch | Entra does not allow the localhost callback | Add `http://localhost` under Mobile and desktop applications | +| Consent required | Tenant policy blocks unconsented API permissions | Ask an admin to grant consent | +| 403 Forbidden | BC rejected the user or app authorization | Assign the right BC permission sets and company access | +| Wrong account | Browser reused another Microsoft login | Run `bcli auth login --incognito` | +| Secret missing | Automation profile cannot find a secret | Run `bcli auth store-secret` or set the configured env var | diff --git a/docs/command-reference.md b/docs/command-reference.md index f96659a..c2fa64b 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -26,12 +26,23 @@ bcli [global-options] [command-options] ### config init -Interactive setup wizard. Discovers companies automatically. +Interactive setup wizard. Defaults to browser auth and discovers companies +automatically. ```bash bcli config init +bcli config init --automation +bcli config init --headless ``` +| Option | Description | +|--------|-------------| +| `--auth ` | `browser`, `client-credentials`, or `device-code` | +| `--automation` | Shortcut for client credentials | +| `--headless` | Shortcut for device code | +| `--scoped` | Hide standard APIs; only imported endpoints are visible | +| `--import ` | Import custom endpoints after profile creation | + ### config show Print resolved configuration (secrets redacted). @@ -77,14 +88,12 @@ bcli auth login [--method ] [--incognito] | Option | Short | Description | |--------|-------|-------------| -| `--method ` | `-m` | `workos`, `browser`, `device`, or `client_credentials` (default: profile's `auth_method`) | +| `--method ` | `-m` | `browser`, `device`, or `client_credentials` (default: profile's `auth_method`) | | `--incognito` | `-i` | Open the browser in incognito/private mode — useful for logging in as a different user | Examples: ```bash bcli auth login # uses profile's auth_method -bcli auth login --method workos # WorkOS SSO → role-based BC access -bcli auth login --method workos -i # incognito — log in as a different user bcli auth login --method browser # browser OAuth (user's BC permissions, PKCE) bcli auth login --method device # device code flow bcli auth login --method client_credentials # service-to-service diff --git a/docs/configuration.md b/docs/configuration.md index c4878e2..c41470f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,45 +16,50 @@ tenant_id = "c6aabf12-1e7a-410a-bd33-c09d6cb294d7" environment = "Production" company_id = "f99bd320-b400-4189-b3c1-c62c05d4e7a5" company_name = "CRONUS USA, Inc." -auth_method = "client_credentials" +auth_method = "browser" client_id = "48074c7f-5706-40d8-aa7d-7be7b33e2df7" -client_secret_env = "BCLI_SECRET" -[profiles.sandbox] +[profiles.automation] tenant_id = "c6aabf12-1e7a-410a-bd33-c09d6cb294d7" -environment = "Sandbox" -company_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" +environment = "Production" +company_id = "f99bd320-b400-4189-b3c1-c62c05d4e7a5" auth_method = "client_credentials" -client_id = "48074c7f-5706-40d8-aa7d-7be7b33e2df7" -client_secret_env = "BCLI_SANDBOX_SECRET" +client_id = "9a12d8e3-1111-2222-3333-7be7b33e2df7" +client_secret_env = "BCLI_SECRET" ``` ## Profiles -Profiles let you manage multiple BC connections — different tenants, environments, or app registrations. +Profiles let you manage multiple BC connections: different tenants, +environments, companies, or auth modes. -### Create a Profile +### Create A Profile ```bash -# Interactive +# Local human/agent use: browser auth bcli config init -# Type a new profile name when prompted -# Manual +# Automation/CI/server use: client credentials +bcli config init --automation + +# SSH/headless fallback: device code +bcli config init --headless +``` + +Manual setup also works: + +```bash bcli config set profiles.sandbox.tenant_id "your-tenant-id" bcli config set profiles.sandbox.environment "Sandbox" +bcli config set profiles.sandbox.auth_method "browser" bcli config set profiles.sandbox.client_id "your-client-id" -bcli config set profiles.sandbox.client_secret_env "BCLI_SANDBOX_SECRET" ``` ### Switch Profiles ```bash -# Set the default profile bcli config use production bcli config use sandbox - -# Use a profile for a single command bcli -p sandbox get customers --top 5 ``` @@ -64,18 +69,32 @@ bcli -p sandbox get customers --top 5 bcli config show ``` +## Scoped Profiles + +Scoped profiles are useful for domain teams. They hide the standard v2.0 +catalog and show only imported custom endpoints. + +```bash +bcli config init --profile ops --scoped --import warehouse.postman_collection.json +``` + +Scoped profiles still use browser auth by default. Use `--headless` only when a +localhost browser callback is not possible. + ## Config Resolution Order -bcli merges configuration from multiple sources. Later sources override earlier ones: +bcli merges configuration from multiple sources. Later sources override earlier +ones: -1. **Global config** — `~/.config/bcli/config.toml` -2. **Project config** — `.bcli.toml` in the current directory or any parent (useful for per-project defaults) -3. **Environment variables** — `BCLI_PROFILE`, `BCLI_FORMAT`, `BCLI_TIMEOUT` -4. **CLI flags** — `--profile`, `--env`, `--company`, `--format` +1. Global config: `~/.config/bcli/config.toml` +2. Project config: `.bcli.toml` in the current directory or a parent +3. Environment variables: `BCLI_PROFILE`, `BCLI_FORMAT`, `BCLI_TIMEOUT` +4. CLI flags: `--profile`, `--env`, `--company`, `--format` ## Project-Level Config -Create a `.bcli.toml` in your project directory to override defaults for that project: +Create a `.bcli.toml` in your project directory to override defaults for that +project: ```toml [defaults] @@ -83,8 +102,6 @@ profile = "sandbox" format = "json" ``` -Anyone working in that directory will automatically use those settings. - ## Environment Variables | Variable | Overrides | @@ -97,7 +114,8 @@ Anyone working in that directory will automatically use those settings. ## Custom API Defaults -If you frequently query custom APIs without importing a registry, you can set defaults: +If you frequently query custom APIs without importing a registry, you can set +route defaults: ```toml [profiles.production] @@ -106,7 +124,8 @@ api_group = "integration" api_version = "v1.0" ``` -These are used when an endpoint isn't found in any registry and no `--publisher/--group/--version` flags are provided. +Imported endpoint registries are preferred; route defaults are only an escape +hatch for ad-hoc access. ## File Locations @@ -115,4 +134,5 @@ These are used when an endpoint isn't found in any registry and no `--publisher/ | `~/.config/bcli/config.toml` | Main configuration | | `~/.config/bcli/tokens.json` | Cached auth tokens | | `~/.config/bcli/registries/*.json` | Imported custom API registries | +| `~/.config/bcli/queries/*.yaml` | Saved queries | | `.bcli.toml` | Project-level config override | diff --git a/docs/getting-started.md b/docs/getting-started.md index 7de9924..9eb586b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,19 +4,21 @@ - Python 3.11 or later - A Business Central online environment -- An Azure Entra ID (Azure AD) app registration with `API.ReadWrite.All` application permission for BC +- An Entra app registration configured for bcli +- Business Central permissions for the signed-in user + +If you do not already know how to create the Entra app or assign Business +Central permissions, use [Business Central Admin Setup](business-central-admin-setup.md) +first. ## Install -The PyPI distribution name is **`bc-cli`** (not `bcli` — that name is squatted -by an unrelated 2018 EC2-cluster package). Once installed, the CLI binary is -still `bcli`. +The PyPI distribution name is **`bc-cli`**. Once installed, the binary is +`bcli`. ```bash -# Recommended uv tool install bc-cli - -# Or via pip +# or pip install bc-cli ``` @@ -26,9 +28,10 @@ Verify the installation: bcli --version ``` -## First-Time Setup +## Local Human Or Agent Setup -Run the interactive setup wizard: +Browser auth is the default. It uses your Microsoft sign-in, needs no client +secret, and Business Central enforces your normal permission sets. ```bash bcli config init @@ -38,80 +41,72 @@ You'll be prompted for: | Prompt | What to enter | |--------|--------------| -| Profile name | A name for this connection (e.g., `production`, `sandbox`) | -| Tenant ID | Your Azure AD tenant ID (GUID) | -| Environment name | BC environment name (e.g., `Production`, `Sandbox`) | -| Client ID | The app registration's Application (client) ID | -| Client secret env var name | Name of an env var holding the secret (e.g., `BCLI_SECRET`) | +| Profile name | A name for this connection, such as `production` or `sandbox` | +| Tenant ID | Your Entra tenant ID | +| Environment name | BC environment name, such as `Production` or `Sandbox` | +| Client ID | The Entra app registration's Application (client) ID | -After authenticating, bcli discovers all companies in your environment and lets you pick a default: +When prompted, authenticate in the browser so bcli can discover companies and +set a default company. -``` -✓ Authenticating... -✓ Discovering companies... +## Automation Setup - # Company Name Company ID - 1 CRONUS USA, Inc. f99bd320-b400-... - 2 My Company a1b2c3d4-e5f6-... +For CI/CD, servers, and scheduled jobs, use client credentials: -? Select default company [1]: 1 - -✓ Config saved to ~/.config/bcli/config.toml -✓ Standard v2.0 APIs ready (79 entities) +```bash +bcli config init --automation +bcli auth store-secret ``` -## Store Your Secret Securely +This path requires an Entra app with application permissions and either an OS +keychain secret or an environment variable such as `BCLI_SECRET`. + +## Headless Setup -Instead of using environment variables, store the secret in your OS keychain: +For SSH sessions where browser callback auth cannot work: ```bash -bcli auth store-secret -# Enter your client secret (hidden input) +bcli config init --headless +bcli auth login --method device ``` -This stores the secret in macOS Keychain (or Windows Credential Manager). No env vars needed after this. - ## Your First Query ```bash -# List customers bcli get customers --top 5 - -# Filter with OData bcli get vendors --filter "displayName eq 'Fabrikam'" - -# Select specific fields bcli get items --select number,displayName,unitPrice --top 10 - -# Output as JSON (for piping to jq) bcli -f json get salesInvoices --top 3 ``` ## Explore Available Endpoints ```bash -# List all standard v2.0 endpoints -bcli endpoint list - -# Search for an endpoint bcli endpoint search vendor - -# Get details about an endpoint bcli endpoint info customers +bcli endpoint fields customers +``` + +For custom APIs, import the registry first: + +```bash +bcli registry import --from-postman ./my_collection.json +bcli get myCustomEntities --top 5 ``` ## Test Your Connection ```bash -bcli test connection # Test auth + API reachability -bcli test auth # Test auth only -bcli test endpoint customers # Test a specific endpoint +bcli test connection +bcli test auth +bcli test endpoint customers ``` ## Next Steps -- [Configuration](configuration.md) — Set up multiple profiles and environments -- [Authentication](authentication.md) — Device code flow, keychain details -- [Custom APIs](custom-apis.md) — Import your custom API pages -- [Multi-Company](multi-company.md) — Set up company aliases -- [Demo Setup (CRONUS)](demo-setup.md) — Stand up a free sandbox with Microsoft's demo company +- [Business Central Admin Setup](business-central-admin-setup.md) — Entra and BC setup from scratch +- [Authentication](authentication.md) — Browser, automation, and headless auth +- [Configuration](configuration.md) — Profiles, environments, and config files +- [Custom APIs](custom-apis.md) — Import custom API pages +- [Saved Queries](saved-queries.md) — Named business questions with no OData +- [MCP Server](mcp-server.md) — Use bcli from MCP-aware agents diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 9f69510..99be60e 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -19,8 +19,8 @@ pip install "bc-cli[mcp]" uv tool install "bc-cli[mcp]" ``` -The `mcp` extra brings in the `cli` extras (typer/rich/pyyaml/keyring/workos) -plus the `mcp` package itself, since the server subprocesses bcli. +The `mcp` extra brings in the MCP package itself. The CLI runtime ships with +the base `bc-cli` install because the server subprocesses `bcli`. After install, the `bcli-mcp` console script is on PATH: diff --git a/docs/saved-queries.md b/docs/saved-queries.md index 308d374..0b79980 100644 --- a/docs/saved-queries.md +++ b/docs/saved-queries.md @@ -150,9 +150,9 @@ bcli config init --profile ops --scoped \ # 2. Admin authors ~/.config/bcli/queries/ops.yaml with 5–10 daily questions # 3. End user runs queries without touching OData -bcli auth login --profile ops # one-time browser sign-in -bcli q --profile ops # see what's available -bcli q --profile ops items-low-stock min=10 +bcli --profile ops auth login # one-time browser sign-in +bcli --profile ops q # see what's available +bcli --profile ops q items-low-stock min=10 ``` ## Useful flags diff --git a/pyproject.toml b/pyproject.toml index 2e8fb26..6043799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "hatchling.build" # installed CLI binary (`bcli`) are unaffected — only `pip install` / # `uv tool install` use this name. name = "bc-cli" -version = "0.1.4" +version = "0.1.5" description = "Python SDK and CLI for Microsoft Dynamics 365 Business Central APIs" readme = "README.md" license = "Apache-2.0" @@ -31,8 +31,12 @@ classifiers = [ ] dependencies = [ "httpx>=0.27", + "keyring>=25.0", "msal>=1.28", "pydantic>=2.0", + "pyyaml>=6.0", + "rich>=13.0", + "typer>=0.12", "tomlkit>=0.13", ] @@ -47,13 +51,8 @@ bcli = "bcli_cli.app:app" bcli-mcp = "bcli_mcp:main" [project.optional-dependencies] -cli = [ - "typer>=0.12", - "rich>=13.0", - "pyyaml>=6.0", - "keyring>=25.0", - "workos>=5.0", -] +# Backward-compatible no-op extra for existing install commands. +cli = [] etl = [ "dlt[parquet,filesystem,s3]>=1.0", ] @@ -61,9 +60,6 @@ telemetry = [ "azure-monitor-opentelemetry>=1.6", ] mcp = [ - # The MCP server subprocesses bcli, which means it needs the CLI - # extras (typer, rich, pyyaml, keyring, workos) on PATH at runtime. - "bc-cli[cli]", "mcp>=1.0", ] polaris = [ @@ -72,7 +68,7 @@ polaris = [ "pyiceberg[s3fs]>=0.7", ] dev = [ - "bc-cli[cli]", + "bc-cli[etl]", "bc-cli[mcp]", "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/src/bcli/auth/_browser.py b/src/bcli/auth/_browser.py index 865ffc1..b4a559b 100644 --- a/src/bcli/auth/_browser.py +++ b/src/bcli/auth/_browser.py @@ -220,7 +220,7 @@ def log_message(self, format: str, *args: object) -> None: # MSAL handles PKCE automatically via initiate_auth_code_flow flow_kwargs: dict[str, str] = {} if self._login_hint: - # Pre-fill the email and skip account picker (coming from WorkOS) + # Pre-fill the email and skip the account picker when callers know it. flow_kwargs["login_hint"] = self._login_hint else: # Standalone browser auth — show account picker diff --git a/src/bcli/auth/_secure_io.py b/src/bcli/auth/_secure_io.py index f738751..4b7eb54 100644 --- a/src/bcli/auth/_secure_io.py +++ b/src/bcli/auth/_secure_io.py @@ -1,8 +1,8 @@ """Helpers for writing auth-sensitive files with private (0600) permissions. -Token caches, WorkOS identity caches, and anything else that contains a -bearer credential go through these helpers so the on-disk artefact isn't -left readable by other local users on shared / permissive-umask systems. +Token caches and anything else that contains a bearer credential go through +these helpers so the on-disk artefact isn't left readable by other local +users on shared / permissive-umask systems. Behaviour: diff --git a/src/bcli/auth/_workos.py b/src/bcli/auth/_workos.py deleted file mode 100644 index 4ed07f1..0000000 --- a/src/bcli/auth/_workos.py +++ /dev/null @@ -1,313 +0,0 @@ -"""WorkOS AuthKit integration for role-based BC client_id selection. - -Flow: -1. User authenticates via WorkOS AuthKit (browser redirect) -2. WorkOS returns user identity + organization memberships -3. bcli maps the user's role to a BC Entra app client_id -4. BC browser auth runs with the selected client_id -5. BC enforces that app's permission sets on every API call - -This gives you SSO + role-based BC access in one login flow. -""" - -from __future__ import annotations - -import json -import logging -import secrets -import sys -import threading -import time -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any -from urllib.parse import parse_qs, urlparse - -from bcli.auth._browser import BrowserAuth, _open_browser -from bcli.auth._secure_io import warn_if_insecure_perms, write_secret_file -from bcli.auth._token_cache import TokenCache -from bcli.config._defaults import CONFIG_DIR -from bcli.errors import AuthError - -logger = logging.getLogger(__name__) - -_WORKOS_PORT = 8401 # separate from BC auth port (8400) -_WORKOS_REDIRECT_URI = f"http://localhost:{_WORKOS_PORT}/callback" -_AUTH_TIMEOUT = 120 -_WORKOS_IDENTITY_FILE = CONFIG_DIR / "workos_identity.json" - -# How long a cached WorkOS role mapping is trusted before we re-validate -# membership. The cache stores `role → privileged BC app client_id`, so -# stale cache means a revoked role keeps mapping to the privileged app -# until manually cleared. One hour balances re-auth UX (browser popup -# every hour at most) against the size of the trust window. -_WORKOS_CACHE_TTL_SECONDS = 3600 - - -class WorkOSAuth: - """Two-step auth: WorkOS identity → role-based BC client_id → BC browser auth. - - Config (in config.toml): - [workos] - api_key = "sk_live_..." - client_id = "client_..." - - [workos.groups] - admin = { roles = ["admin"], bc_client_id = "7c25b4eb-..." } - readonly = { roles = ["member"], bc_client_id = "6db881e3-..." } - """ - - def __init__( - self, - *, - tenant_id: str, - workos_api_key: str, - workos_client_id: str, - role_mapping: dict[str, str], - default_bc_client_id: str, - token_cache: TokenCache | None = None, - incognito: bool = False, - ) -> None: - self._tenant_id = tenant_id - self._workos_api_key = workos_api_key - self._workos_client_id = workos_client_id - self._role_mapping = role_mapping # {role_slug: bc_client_id} - self._default_bc_client_id = default_bc_client_id - self._incognito = incognito - self._token_cache = token_cache or TokenCache() - self._bc_auth: BrowserAuth | None = None - - async def get_access_token(self) -> str: - """Get a BC access token via WorkOS-gated browser auth.""" - # Check if we have a cached BC token - bc_client_id = self._resolve_bc_client_id() - cached = self._token_cache.get(self._tenant_id, bc_client_id) - if cached: - return cached - - # Need to authenticate — determine BC client_id from WorkOS identity - email: str | None = None - identity = _load_workos_identity() - if identity: - email = identity.get("email") - - if bc_client_id == self._default_bc_client_id: - # No cached WorkOS identity — do the full WorkOS flow - bc_client_id, email = self._workos_login() - - # Now do BC browser auth with the resolved client_id - # Pass email as login_hint to skip the account picker - self._bc_auth = BrowserAuth( - tenant_id=self._tenant_id, - client_id=bc_client_id, - token_cache=self._token_cache, - login_hint=email, - incognito=self._incognito, - ) - return await self._bc_auth.get_access_token() - - def _resolve_bc_client_id(self) -> str: - """Check cached WorkOS identity for role → client_id mapping. - - The cache is rejected if it's older than ``_WORKOS_CACHE_TTL_SECONDS`` - so a revoked WorkOS role can't keep mapping to a privileged BC app - forever. On expiry we return the default client_id, which causes - the caller to fall through to ``_workos_login()`` and re-fetch - membership from WorkOS — the only authoritative source. - """ - identity = _load_workos_identity() - if not identity: - return self._default_bc_client_id - - cached_at = identity.get("cached_at") - if not isinstance(cached_at, (int, float)): - # Pre-TTL cache or corrupt timestamp → treat as expired and - # force a fresh membership check. - logger.debug("WorkOS cache lacks cached_at — re-validating.") - return self._default_bc_client_id - - age = time.time() - float(cached_at) - if age > _WORKOS_CACHE_TTL_SECONDS: - logger.debug( - "WorkOS cache age %.0fs > TTL %ds — re-validating membership.", - age, _WORKOS_CACHE_TTL_SECONDS, - ) - return self._default_bc_client_id - - role = identity.get("role", "") - bc_client_id = self._role_mapping.get(role) - if bc_client_id: - logger.debug( - "WorkOS cached identity: role=%s → client_id=%s (age %.0fs)", - role, bc_client_id[:8], age, - ) - return bc_client_id - return self._default_bc_client_id - - def _workos_login(self) -> tuple[str, str | None]: - """Run WorkOS browser auth, get user role, return (bc_client_id, email).""" - try: - from workos import WorkOSClient as WorkOS - except ImportError as e: - raise AuthError( - f"WorkOS SDK not available: {e}. Run: pip install workos", - status_code=401, - ) - - client = WorkOS(api_key=self._workos_api_key, client_id=self._workos_client_id) - - # High-entropy, per-login state token. The localhost callback handler - # below refuses any callback whose `state` query parameter doesn't - # match. Without this binding, any unsolicited GET to - # http://127.0.0.1:8401/callback?code=... during the login window - # (e.g. from another local process or a malicious page reachable to - # the loopback interface) would be treated as a legitimate WorkOS - # response and its authorization code would be exchanged for a - # role-bearing identity. See vuln-0001. - expected_state = secrets.token_urlsafe(32) - - # Generate auth URL - auth_url = client.user_management.get_authorization_url( - redirect_uri=_WORKOS_REDIRECT_URI, - provider="authkit", - state=expected_state, - ) - - # Start localhost server for callback - auth_response: dict[str, Any] = {} - server_error: list[str] = [] - - class CallbackHandler(BaseHTTPRequestHandler): - def do_GET(self) -> None: - parsed = urlparse(self.path) - params = parse_qs(parsed.query) - - if parsed.path != "/callback": - self.send_response(404) - self.end_headers() - return - - callback_state = params.get("state", [""])[0] - if not secrets.compare_digest(callback_state, expected_state): - server_error.append("Invalid WorkOS callback state.") - self.send_response(400) - self.send_header("Content-Type", "text/html") - self.end_headers() - self.wfile.write( - b"

Authentication failed

" - b"

You can close this tab.

" - ) - return - - auth_response.update({k: v[0] if len(v) == 1 else v for k, v in params.items()}) - - has_error = "error" in params - if has_error: - server_error.append(params.get("error_description", params.get("error", ["Unknown"]))[0]) - - self.send_response(200) - self.send_header("Content-Type", "text/html") - self.end_headers() - msg = "Authentication failed" if has_error else "WorkOS login successful. Continuing to Business Central..." - self.wfile.write(f"

{msg}

You can close this tab.

".encode()) - - def log_message(self, format: str, *args: object) -> None: - pass - - server = HTTPServer(("127.0.0.1", _WORKOS_PORT), CallbackHandler) - server.timeout = _AUTH_TIMEOUT - - mode = " (incognito)" if self._incognito else "" - print(f"\nOpening browser for WorkOS login{mode}...", file=sys.stderr) - print(f"If the browser doesn't open, visit:\n {auth_url}\n", file=sys.stderr) - _open_browser(auth_url, incognito=self._incognito) - - server_thread = threading.Thread(target=server.handle_request, daemon=True) - server_thread.start() - server_thread.join(timeout=_AUTH_TIMEOUT) - server.server_close() - - # `server_error` first: a callback that arrived but failed validation - # (state mismatch, WorkOS-reported error) should surface as an explicit - # auth failure rather than getting masked by the "no callback at all" - # timeout message. - if server_error: - raise AuthError(f"WorkOS authentication failed: {server_error[0]}", status_code=401) - if not auth_response: - raise AuthError("WorkOS authentication timed out.", status_code=401) - - code = auth_response.get("code") - if not code: - raise AuthError("No authorization code received from WorkOS.", status_code=401) - - # Exchange code for user info - result = client.user_management.authenticate_with_code( - code=code, - ) - - user = result.user - print(f"WorkOS: logged in as {user.email}", file=sys.stderr) - - # Get organization memberships to determine role - memberships = client.user_management.list_organization_memberships(user_id=user.id) - - role_slug = "member" # default - for membership in memberships.data: - if membership.status == "active": - role = membership.role - role_slug = role["slug"] if isinstance(role, dict) else getattr(role, "slug", "member") - break - - # Map role to BC client_id - bc_client_id = self._role_mapping.get(role_slug, self._default_bc_client_id) - - print(f"WorkOS: role={role_slug} → BC app {'admin' if bc_client_id != self._default_bc_client_id else 'standard'}", file=sys.stderr) - - # Cache WorkOS identity. ``cached_at`` is a unix epoch float used by - # ``_resolve_bc_client_id`` to expire stale role mappings (see - # ``_WORKOS_CACHE_TTL_SECONDS``). - _save_workos_identity({ - "user_id": user.id, - "email": user.email, - "role": role_slug, - "bc_client_id": bc_client_id, - "cached_at": time.time(), - }) - - return bc_client_id, user.email - - def clear_cache(self) -> None: - """Clear BC token cache and WorkOS identity.""" - if self._bc_auth: - self._bc_auth.clear_cache() - _clear_workos_identity() - - -def _load_workos_identity() -> dict | None: - """Load cached WorkOS identity from disk.""" - if _WORKOS_IDENTITY_FILE.is_file(): - warn_if_insecure_perms(_WORKOS_IDENTITY_FILE) - try: - return json.loads(_WORKOS_IDENTITY_FILE.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - return None - return None - - -def _save_workos_identity(identity: dict) -> None: - """Cache WorkOS identity to disk with private (0600) permissions. - - The cached identity steers role → BC-app-client-id mapping (see - ``_resolve_bc_client_id``), so it carries the same blast radius as a - bearer token: another local user reading it could see who has admin - BC access and impersonate that role lookup. - """ - write_secret_file( - _WORKOS_IDENTITY_FILE, - json.dumps(identity, indent=2), - ) - - -def _clear_workos_identity() -> None: - """Remove cached WorkOS identity.""" - if _WORKOS_IDENTITY_FILE.is_file(): - _WORKOS_IDENTITY_FILE.unlink() diff --git a/src/bcli/client/_async.py b/src/bcli/client/_async.py index bfdd124..46c0178 100644 --- a/src/bcli/client/_async.py +++ b/src/bcli/client/_async.py @@ -88,25 +88,6 @@ def _build_auth( config: BCConfig | None = None, ): """Build auth provider from profile config or programmatic credentials.""" - if profile.auth_method == "workos": - from bcli.auth._workos import WorkOSAuth - - workos_cfg = config.workos if config else None - if not workos_cfg or not workos_cfg.api_key: - from bcli.errors import ConfigError - raise ConfigError( - "WorkOS auth requires [workos] section in config.toml with api_key and client_id." - ) - role_mapping = workos_cfg.get_role_mapping() - # Default BC client_id comes from the profile - return WorkOSAuth( - tenant_id=profile.tenant_id, - workos_api_key=workos_cfg.api_key, - workos_client_id=workos_cfg.client_id, - role_mapping=role_mapping, - default_bc_client_id=profile.client_id or "", - ) - if profile.auth_method == "browser": from bcli.auth._browser import BrowserAuth @@ -123,6 +104,12 @@ def _build_auth( client_id=profile.client_id or "", ) + if profile.auth_method not in ("client_credentials", "client-credentials"): + raise ConfigError( + f"Unsupported auth_method '{profile.auth_method}'. " + "Use 'browser', 'device_code', or 'client_credentials'." + ) + # Client credentials — programmatic secret takes priority over env var return ClientCredentialsAuth( tenant_id=profile.tenant_id, diff --git a/src/bcli/config/_model.py b/src/bcli/config/_model.py index 4c25b33..5851792 100644 --- a/src/bcli/config/_model.py +++ b/src/bcli/config/_model.py @@ -116,31 +116,6 @@ class BCDefaults(BaseModel): model_config = {"extra": "allow"} -class WorkOSRoleMapping(BaseModel): - """Maps a WorkOS role slug to a BC Entra app client_id.""" - - roles: list[str] - bc_client_id: str - - -class WorkOSConfig(BaseModel): - """WorkOS AuthKit configuration for role-based BC access.""" - - api_key: str = "" - client_id: str = "" - groups: dict[str, WorkOSRoleMapping] = Field(default_factory=dict) - - def get_role_mapping(self) -> dict[str, str]: - """Build a flat {role_slug: bc_client_id} mapping.""" - mapping: dict[str, str] = {} - for group in self.groups.values(): - for role in group.roles: - mapping[role] = group.bc_client_id - return mapping - - model_config = {"extra": "allow"} - - class TelemetryConfig(BaseModel): """Optional usage-telemetry sink for bcli — plug-and-play backend. @@ -184,9 +159,10 @@ class BCConfig(BaseModel): defaults: BCDefaults = Field(default_factory=BCDefaults) profiles: dict[str, BCProfile] = Field(default_factory=dict) - workos: WorkOSConfig = Field(default_factory=WorkOSConfig) telemetry: TelemetryConfig = Field(default_factory=TelemetryConfig) + model_config = {"extra": "allow"} + def get_profile(self, name: str | None = None) -> BCProfile: """Get a profile by name, falling back to the default.""" profile_name = name or self.defaults.profile diff --git a/src/bcli_cli/commands/auth_cmd.py b/src/bcli_cli/commands/auth_cmd.py index f2e48e9..492bcb7 100644 --- a/src/bcli_cli/commands/auth_cmd.py +++ b/src/bcli_cli/commands/auth_cmd.py @@ -20,7 +20,7 @@ def login( method: str | None = typer.Option( None, "--method", "-m", - help="Auth method: workos, browser, device, client_credentials (default: profile's auth_method)", + help="Auth method: browser, device, client_credentials (default: profile's auth_method)", ), incognito: bool = typer.Option( False, "--incognito", "-i", @@ -32,32 +32,14 @@ def login( \b Examples: bcli auth login # uses profile's auth_method - bcli auth login --method workos # WorkOS SSO → role-based BC access - bcli auth login --method workos -i # incognito — log in as a different user bcli auth login --method browser # browser OAuth (user's BC permissions) bcli auth login --method device # device code flow bcli auth login --method client_credentials # service-to-service """ profile = state.profile - auth_method = method or profile.auth_method + auth_method = _normalise_auth_method(method or profile.auth_method) - if auth_method == "workos": - console.print(f"[dim]WorkOS auth for tenant {profile.tenant_id}...[/dim]") - from bcli.auth._workos import WorkOSAuth - - workos_cfg = state.config.workos - if not workos_cfg.api_key: - console.print("[red]WorkOS requires [workos] section in config.toml with api_key and client_id.[/red]") - raise typer.Exit(1) - auth = WorkOSAuth( - tenant_id=profile.tenant_id, - workos_api_key=workos_cfg.api_key, - workos_client_id=workos_cfg.client_id, - role_mapping=workos_cfg.get_role_mapping(), - default_bc_client_id=profile.client_id or "", - incognito=incognito, - ) - elif auth_method == "browser": + if auth_method == "browser": console.print(f"[dim]Browser auth for tenant {profile.tenant_id}...[/dim]") from bcli.auth._browser import BrowserAuth @@ -66,7 +48,7 @@ def login( client_id=profile.client_id or "", incognito=incognito, ) - elif auth_method in ("device", "device_code"): + elif auth_method == "device_code": console.print(f"[dim]Device code auth for tenant {profile.tenant_id}...[/dim]") from bcli.auth._device_code import DeviceCodeAuth @@ -110,6 +92,24 @@ def login( raise typer.Exit(1) +def _normalise_auth_method(raw: str) -> str: + """Return the canonical auth method or exit with a user-facing error.""" + method = raw.strip().lower().replace("-", "_") + aliases = { + "device": "device_code", + "device_code": "device_code", + "browser": "browser", + "client_credentials": "client_credentials", + } + if method in aliases: + return aliases[method] + console.print( + f"[red]Unsupported auth method '{raw}'.[/red] " + "Use browser, device_code, or client_credentials." + ) + raise typer.Exit(1) + + @app.command() def status() -> None: """Show current token cache status.""" diff --git a/src/bcli_cli/commands/config_cmd.py b/src/bcli_cli/commands/config_cmd.py index 2caa226..d23f564 100644 --- a/src/bcli_cli/commands/config_cmd.py +++ b/src/bcli_cli/commands/config_cmd.py @@ -30,9 +30,21 @@ def init( None, "--profile", "-p", help="Profile name (skips the interactive prompt)", ), + auth: Optional[str] = typer.Option( + None, "--auth", + help="Auth method: browser, client-credentials, or device-code. Defaults to browser.", + ), + automation: bool = typer.Option( + False, "--automation", + help="Use client credentials for CI, servers, and background jobs.", + ), + headless: bool = typer.Option( + False, "--headless", + help="Use device-code auth for SSH/headless hosts without localhost browser callbacks.", + ), scoped: bool = typer.Option( False, "--scoped", - help="Sandboxed-domain mode for non-developer users: device-code " + help="Sandboxed-domain mode for non-developer users: delegated " "auth (no client secret) and only the endpoints you --import " "are visible. The standard v2.0 catalog is hidden.", ), @@ -52,15 +64,22 @@ def init( \b Examples: - bcli config init # standard wizard + bcli config init # browser auth wizard + bcli config init --automation # client credentials + bcli config init --headless # device code bcli config init --profile myteam --scoped --import endpoints.json bcli config init --profile myteam --scoped \\ --category warehouse --import warehouse.postman_collection.json """ console.print("[bold]bcli config init[/bold]\n") + auth_method = _resolve_init_auth_method( + auth=auth, + automation=automation, + headless=headless, + ) if scoped: bullets = [ - "device-code auth (corporate login, no client secret)", + f"{_auth_label(auth_method)} auth (corporate login, no client secret)", "standard v2.0 catalog hidden — only imported endpoints are visible", ] if category: @@ -77,10 +96,10 @@ def init( # Secret handling — only relevant for client_credentials auth. secret_env = None - if scoped: + if auth_method != "client_credentials": console.print( - "[dim]Skipping client secret — device-code auth uses your " - "corporate login.[/dim]" + f"[dim]Skipping client secret — {_auth_label(auth_method)} auth " + "uses delegated user login.[/dim]" ) elif ClientCredentialsAuth.has_keyring(): use_keychain = Confirm.ask("Store client secret in OS keychain? (recommended)", default=True) @@ -103,18 +122,21 @@ def init( profile_obj = BCProfile( tenant_id=tenant_id, environment=environment, - auth_method="device_code" if scoped else "client_credentials", + auth_method=auth_method, client_id=client_id, client_secret_env=secret_env, disable_standard_api=scoped, allowed_categories=list(category) if category else [], ) - # Try to authenticate and discover companies. In scoped mode the user has - # to complete a browser device flow first, so we ask before launching it. - skip_discovery = scoped and not Confirm.ask( - "Authenticate now via device code to auto-discover companies?", - default=True, + # Try to authenticate and discover companies. Delegated auth opens a browser + # or prints a device code, so ask before launching it. + skip_discovery = ( + auth_method in ("browser", "device_code") + and not Confirm.ask( + f"Authenticate now via {_auth_label(auth_method)} to auto-discover companies?", + default=True, + ) ) if not skip_discovery: console.print("\n[dim]Authenticating...[/dim]") @@ -125,7 +147,7 @@ def init( client_id=client_id, environment=environment, secret_env=secret_env, - use_device_code=scoped, + auth_method=auth_method, ) ) @@ -166,7 +188,7 @@ def init( console.print("[dim]Saving config anyway — you can test the connection later.[/dim]") else: console.print( - "[dim]Skipped. Run 'bcli auth login --profile " + profile_name + + "[dim]Skipped. Run 'bcli --profile " + profile_name + " auth login" "' then 'bcli company list' to set the default company.[/dim]" ) @@ -196,8 +218,8 @@ def init( console.print("\n[dim]Next:[/dim]") if scoped: - console.print(f"[dim] bcli auth login --profile {profile_name}[/dim]") - console.print(f"[dim] bcli endpoint list --profile {profile_name}[/dim]") + console.print(f"[dim] bcli --profile {profile_name} auth login[/dim]") + console.print(f"[dim] bcli --profile {profile_name} endpoint list[/dim]") else: console.print("[dim] bcli get customers --top 5[/dim]") @@ -208,10 +230,14 @@ async def _discover_via_auth( client_id: str, environment: str, secret_env: str | None, - use_device_code: bool, + auth_method: str, ) -> list[dict]: - """Authenticate (device code or client creds) and list companies.""" - if use_device_code: + """Authenticate with the selected method and list companies.""" + if auth_method == "browser": + from bcli.auth._browser import BrowserAuth + + auth = BrowserAuth(tenant_id=tenant_id, client_id=client_id) + elif auth_method == "device_code": from bcli.auth._device_code import DeviceCodeAuth auth = DeviceCodeAuth(tenant_id=tenant_id, client_id=client_id) @@ -228,6 +254,58 @@ async def _discover_via_auth( await transport.close() +def _resolve_init_auth_method( + *, + auth: str | None, + automation: bool, + headless: bool, +) -> str: + """Resolve config-init auth flags to one canonical profile value.""" + if automation and headless: + console.print("[red]--automation and --headless are mutually exclusive.[/red]") + raise typer.Exit(1) + + requested = auth + if requested is None: + if automation: + requested = "client-credentials" + elif headless: + requested = "device-code" + else: + requested = "browser" + + method = requested.strip().lower().replace("-", "_") + aliases = { + "browser": "browser", + "device": "device_code", + "device_code": "device_code", + "client_credentials": "client_credentials", + } + if method not in aliases: + console.print( + f"[red]Unsupported auth method '{requested}'.[/red] " + "Use browser, client-credentials, or device-code." + ) + raise typer.Exit(1) + + resolved = aliases[method] + if automation and resolved != "client_credentials": + console.print("[red]--automation requires --auth client-credentials.[/red]") + raise typer.Exit(1) + if headless and resolved != "device_code": + console.print("[red]--headless requires --auth device-code.[/red]") + raise typer.Exit(1) + return resolved + + +def _auth_label(method: str) -> str: + return { + "browser": "browser", + "device_code": "device-code", + "client_credentials": "client-credentials", + }.get(method, method) + + def _import_endpoints_for_profile(profile_name: str, import_file: Path) -> None: """Run a Postman or JSON import for a freshly-created profile. diff --git a/src/bcli_mcp/_runner.py b/src/bcli_mcp/_runner.py index 906db9e..a2cac60 100644 --- a/src/bcli_mcp/_runner.py +++ b/src/bcli_mcp/_runner.py @@ -73,7 +73,7 @@ def run_bcli_json( except FileNotFoundError as exc: raise _ToolError( "bcli executable not found on PATH. Install with " - "'pip install bc-cli[cli]' or 'uv tool install bc-cli'." + "'pip install bc-cli' or 'uv tool install bc-cli'." ) from exc except subprocess.TimeoutExpired as exc: raise _ToolError( @@ -134,7 +134,7 @@ def run_bcli_side_effect( except FileNotFoundError as exc: raise _ToolError( "bcli executable not found on PATH. Install with " - "'pip install bc-cli[cli]' or 'uv tool install bc-cli'." + "'pip install bc-cli' or 'uv tool install bc-cli'." ) from exc except subprocess.TimeoutExpired as exc: raise _ToolError( diff --git a/tests/test_auth/test_workos_cache.py b/tests/test_auth/test_workos_cache.py deleted file mode 100644 index 51c506c..0000000 --- a/tests/test_auth/test_workos_cache.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Tests for WorkOS identity-cache TTL behaviour. - -A revoked WorkOS role must not keep mapping to a privileged BC app forever. -``_resolve_bc_client_id`` re-validates by returning the default client_id -once the cache exceeds ``_WORKOS_CACHE_TTL_SECONDS``, which forces the -caller to run a fresh ``_workos_login()`` and re-fetch organisation -membership from WorkOS. -""" - -from __future__ import annotations - -import json -import time - -from bcli.auth import _workos as workos_module -from bcli.auth._workos import WorkOSAuth, _WORKOS_CACHE_TTL_SECONDS - - -def _make_auth(monkeypatch, tmp_path): - identity_file = tmp_path / "workos_identity.json" - monkeypatch.setattr(workos_module, "_WORKOS_IDENTITY_FILE", identity_file) - return WorkOSAuth( - tenant_id="t", - workos_api_key="k", - workos_client_id="c", - role_mapping={"admin": "admin-client-id", "member": "member-client-id"}, - default_bc_client_id="default-client-id", - ), identity_file - - -def _write_identity(path, *, role: str, cached_at): - payload = { - "user_id": "u", - "email": "u@example.com", - "role": role, - "bc_client_id": "ignored-by-resolver", - } - if cached_at is not None: - payload["cached_at"] = cached_at - path.write_text(json.dumps(payload)) - - -def test_fresh_cache_returns_mapped_client_id(tmp_path, monkeypatch): - auth, identity_file = _make_auth(monkeypatch, tmp_path) - _write_identity(identity_file, role="admin", cached_at=time.time()) - assert auth._resolve_bc_client_id() == "admin-client-id" - - -def test_expired_cache_falls_back_to_default(tmp_path, monkeypatch): - """Cache older than the TTL forces a re-login (default client_id).""" - auth, identity_file = _make_auth(monkeypatch, tmp_path) - expired = time.time() - _WORKOS_CACHE_TTL_SECONDS - 60 - _write_identity(identity_file, role="admin", cached_at=expired) - assert auth._resolve_bc_client_id() == "default-client-id" - - -def test_cache_just_inside_ttl_is_trusted(tmp_path, monkeypatch): - auth, identity_file = _make_auth(monkeypatch, tmp_path) - just_inside = time.time() - (_WORKOS_CACHE_TTL_SECONDS - 60) - _write_identity(identity_file, role="admin", cached_at=just_inside) - assert auth._resolve_bc_client_id() == "admin-client-id" - - -def test_legacy_cache_without_cached_at_forces_revalidation(tmp_path, monkeypatch): - """A pre-fix cache file (no `cached_at` field) is treated as expired. - - This is the upgrade path: a user who logged in before the TTL fix - re-validates on next CLI run instead of trusting an indefinite role. - """ - auth, identity_file = _make_auth(monkeypatch, tmp_path) - _write_identity(identity_file, role="admin", cached_at=None) - assert auth._resolve_bc_client_id() == "default-client-id" - - -def test_corrupt_cached_at_forces_revalidation(tmp_path, monkeypatch): - auth, identity_file = _make_auth(monkeypatch, tmp_path) - _write_identity(identity_file, role="admin", cached_at="yesterday") - assert auth._resolve_bc_client_id() == "default-client-id" - - -def test_missing_cache_file_returns_default(tmp_path, monkeypatch): - auth, _ = _make_auth(monkeypatch, tmp_path) - assert auth._resolve_bc_client_id() == "default-client-id" - - -def test_unknown_role_in_fresh_cache_returns_default(tmp_path, monkeypatch): - """A role that isn't in role_mapping falls through to default. - - (Same behaviour as before the TTL change — verifying it still holds.) - """ - auth, identity_file = _make_auth(monkeypatch, tmp_path) - _write_identity(identity_file, role="contractor", cached_at=time.time()) - assert auth._resolve_bc_client_id() == "default-client-id" diff --git a/tests/test_auth/test_workos_callback_state.py b/tests/test_auth/test_workos_callback_state.py deleted file mode 100644 index 88c0132..0000000 --- a/tests/test_auth/test_workos_callback_state.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Regression tests for vuln-0001 — WorkOS callback state validation. - -Before the fix, ``WorkOSAuth._workos_login()`` started an HTTP listener on -``127.0.0.1:8401`` and trusted any callback that arrived during the login -window: there was no per-login state token, so an unsolicited request to -``http://127.0.0.1:8401/callback?code=FORGED_CODE&state=attacker`` was -exchanged for a role-bearing WorkOS identity and persisted to disk. - -These tests run the real localhost listener with a stubbed WorkOS SDK and -assert that the callback handler now: - - * generates an unpredictable ``state`` and includes it in the - authorization URL; - * 404s any request whose path is not ``/callback``; - * 400s a callback whose ``state`` does not match the expected token, - surfacing it as an auth failure rather than a timeout; - * accepts and proceeds when ``state`` matches. -""" - -from __future__ import annotations - -import sys -import threading -import time -import types -import urllib.error -import urllib.request -from typing import Any - -import pytest - -from bcli.auth import _workos as workos_module -from bcli.auth._workos import WorkOSAuth -from bcli.errors import AuthError - - -# ── Test doubles ────────────────────────────────────────────────────────── - - -class _FakeUser: - id = "victim-local-user" - email = "user@example.com" - - -class _FakeAuthResult: - user = _FakeUser() - - -class _FakeMembership: - status = "active" - role = {"slug": "admin"} - - -class _FakeMemberships: - data = [_FakeMembership()] - - -class _FakeUserManagement: - def __init__(self) -> None: - self.captured_state: str | None = None - self.authenticate_calls: list[str] = [] - - def get_authorization_url(self, **kwargs: Any) -> str: - # Capture the state the caller passed so the test can replay it. - self.captured_state = kwargs.get("state") - return "https://example.invalid/workos/start" - - def authenticate_with_code(self, code: str) -> _FakeAuthResult: - self.authenticate_calls.append(code) - return _FakeAuthResult() - - def list_organization_memberships(self, user_id: str) -> _FakeMemberships: - return _FakeMemberships() - - -class _FakeWorkOSClient: - last_instance: "_FakeWorkOSClient | None" = None - - def __init__(self, api_key: str, client_id: str) -> None: - self.user_management = _FakeUserManagement() - _FakeWorkOSClient.last_instance = self - - -@pytest.fixture -def fake_workos(monkeypatch): - """Install a fake `workos` module + a non-interactive browser opener.""" - _FakeWorkOSClient.last_instance = None - fake_mod = types.ModuleType("workos") - fake_mod.WorkOSClient = _FakeWorkOSClient # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "workos", fake_mod) - monkeypatch.setattr(workos_module, "_open_browser", lambda *a, **kw: None) - # Short timeout so a missing/bad callback doesn't stall the test suite. - monkeypatch.setattr(workos_module, "_AUTH_TIMEOUT", 4) - yield - - -@pytest.fixture -def isolated_identity_cache(monkeypatch, tmp_path): - """Redirect the WorkOS identity cache to a tmp path.""" - cache = tmp_path / "workos_identity.json" - monkeypatch.setattr(workos_module, "_WORKOS_IDENTITY_FILE", cache) - yield cache - - -def _make_auth() -> WorkOSAuth: - return WorkOSAuth( - tenant_id="tenant-1", - workos_api_key="api-key", - workos_client_id="workos-client", - role_mapping={"admin": "privileged-bc-client", "member": "standard-bc-client"}, - default_bc_client_id="standard-bc-client", - ) - - -def _run_login_in_thread(auth: WorkOSAuth) -> tuple[threading.Thread, dict, dict]: - """Run `_workos_login()` in a daemon thread and capture result/error.""" - result: dict = {} - err: dict = {} - - def target() -> None: - try: - result["value"] = auth._workos_login() - except Exception as exc: # noqa: BLE001 — we want to capture all - err["error"] = exc - - t = threading.Thread(target=target, daemon=True) - t.start() - # Give the HTTP listener a moment to bind. - time.sleep(0.3) - return t, result, err - - -# ── Tests ───────────────────────────────────────────────────────────────── - - -def test_authorization_url_includes_state(fake_workos, isolated_identity_cache): - """The login flow must pass a state token to WorkOS.""" - auth = _make_auth() - thread, _result, _err = _run_login_in_thread(auth) - - # We don't need a real callback — just hit the listener once with a - # matching state so the thread exits cleanly. We pull the expected - # state from the fake WorkOS SDK. - fake = _FakeWorkOSClient.last_instance - assert fake is not None, "WorkOS client should have been constructed" - state = fake.user_management.captured_state - assert state, "state must be passed to get_authorization_url" - assert len(state) >= 32, "state must be high-entropy (token_urlsafe(32))" - - # Drain the listener so the daemon thread shuts down promptly. - try: - urllib.request.urlopen( - f"http://127.0.0.1:{workos_module._WORKOS_PORT}/callback" - f"?code=ok&state={state}", - timeout=2, - ) - except (urllib.error.URLError, ConnectionError): - # urllib.error.URLError covers DNS / connection-refused; ConnectionError - # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ - # when the server thread closes the socket while we're still reading - # the response. Either is fine for these drain calls — the test's real - # assertions live elsewhere; this just lets the daemon thread exit. - pass - thread.join(timeout=5) - - -def test_unsolicited_callback_is_rejected(fake_workos, isolated_identity_cache): - """A callback with a wrong state must be rejected as an auth failure. - - Before the fix this was the core exploit: any local actor that could - reach the loopback listener could inject an attacker-controlled code. - """ - auth = _make_auth() - thread, _result, err = _run_login_in_thread(auth) - - url = ( - f"http://127.0.0.1:{workos_module._WORKOS_PORT}/callback" - "?code=FORGED_CODE&state=attacker" - ) - try: - urllib.request.urlopen(url, timeout=2) - except urllib.error.HTTPError as http_err: - # Expected: callback handler returns 400 on state mismatch. - assert http_err.code == 400 - except urllib.error.URLError: - # Listener may already have closed in some race conditions — that's - # fine; the assertion below covers the security guarantee either way. - pass - - thread.join(timeout=5) - - # The login call must have failed, not silently succeeded. - assert "error" in err, "login must raise on rejected callback" - assert isinstance(err["error"], AuthError) - assert "Invalid WorkOS callback state" in str(err["error"]) - - # And — critically — the forged code must NOT have been exchanged. - fake = _FakeWorkOSClient.last_instance - assert fake is not None - assert fake.user_management.authenticate_calls == [], ( - "authenticate_with_code must not be called for an unsolicited callback" - ) - - # Identity cache must not be written. - assert not isolated_identity_cache.exists() - - -def test_callback_with_wrong_path_is_404(fake_workos, isolated_identity_cache): - """Only `/callback` is honoured; other paths must 404 without side effects.""" - auth = _make_auth() - thread, _result, _err = _run_login_in_thread(auth) - - fake = _FakeWorkOSClient.last_instance - assert fake is not None - state = fake.user_management.captured_state - assert state - - # Hit a non-callback path with the correct state — must 404. - bad_url = ( - f"http://127.0.0.1:{workos_module._WORKOS_PORT}/admin" - f"?code=ok&state={state}" - ) - try: - urllib.request.urlopen(bad_url, timeout=2) - except urllib.error.HTTPError as http_err: - assert http_err.code == 404 - except (urllib.error.URLError, ConnectionError): - # urllib.error.URLError covers DNS / connection-refused; ConnectionError - # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ - # when the server thread closes the socket while we're still reading - # the response. Either is fine for these drain calls — the test's real - # assertions live elsewhere; this just lets the daemon thread exit. - pass - - # The 404 path must not consume the listener — the legitimate callback - # below should still be accepted. Drain so the thread exits cleanly. - good_url = ( - f"http://127.0.0.1:{workos_module._WORKOS_PORT}/callback" - f"?code=ok&state={state}" - ) - try: - urllib.request.urlopen(good_url, timeout=2) - except (urllib.error.URLError, ConnectionError): - # urllib.error.URLError covers DNS / connection-refused; ConnectionError - # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ - # when the server thread closes the socket while we're still reading - # the response. Either is fine for these drain calls — the test's real - # assertions live elsewhere; this just lets the daemon thread exit. - pass - - thread.join(timeout=5) - - -def test_matching_state_is_accepted(fake_workos, isolated_identity_cache): - """A callback whose state matches the per-login token must succeed.""" - auth = _make_auth() - thread, result, err = _run_login_in_thread(auth) - - fake = _FakeWorkOSClient.last_instance - assert fake is not None - state = fake.user_management.captured_state - assert state - - url = ( - f"http://127.0.0.1:{workos_module._WORKOS_PORT}/callback" - f"?code=GOOD_CODE&state={state}" - ) - try: - urllib.request.urlopen(url, timeout=2) - except (urllib.error.URLError, ConnectionError): - # urllib.error.URLError covers DNS / connection-refused; ConnectionError - # (incl. ConnectionResetError) leaks through unwrapped on Python 3.12+ - # when the server thread closes the socket while we're still reading - # the response. Either is fine for these drain calls — the test's real - # assertions live elsewhere; this just lets the daemon thread exit. - pass - - thread.join(timeout=5) - - assert "error" not in err, f"login should succeed; got {err.get('error')!r}" - bc_client_id, email = result["value"] - assert bc_client_id == "privileged-bc-client" - assert email == "user@example.com" - assert fake.user_management.authenticate_calls == ["GOOD_CODE"] diff --git a/tests/test_cli/test_config_cmd.py b/tests/test_cli/test_config_cmd.py index c832f45..e7ef3fa 100644 --- a/tests/test_cli/test_config_cmd.py +++ b/tests/test_cli/test_config_cmd.py @@ -7,6 +7,7 @@ import pytest from typer.testing import CliRunner +from bcli.config import load_config from bcli_cli._state import state from bcli_cli.app import app @@ -120,3 +121,74 @@ def missing_editor(cmd, check): result = runner.invoke(app, ["config", "edit"]) assert result.exit_code == 1 assert "not found" in result.stdout + + +def test_config_init_defaults_to_browser_auth(tmp_config): + result = runner.invoke( + app, + ["config", "init", "--profile", "local"], + input="tenant-id\nSandbox\nclient-id\nn\n", + ) + + assert result.exit_code == 0, result.stdout + config = load_config() + profile = config.get_profile("local") + assert profile.auth_method == "browser" + assert profile.client_secret_env is None + assert "Skipping client secret" in result.stdout + + +def test_config_init_automation_uses_client_credentials(tmp_config, monkeypatch): + captured: dict = {} + + async def fake_discover(**kwargs): + captured.update(kwargs) + return [{"name": "CRONUS USA, Inc.", "id": "company-id"}] + + monkeypatch.setattr( + "bcli_cli.commands.config_cmd.ClientCredentialsAuth.has_keyring", + lambda: False, + ) + monkeypatch.setattr("bcli_cli.commands.config_cmd._discover_via_auth", fake_discover) + + result = runner.invoke( + app, + ["config", "init", "--profile", "automation", "--automation"], + input="tenant-id\nProduction\nclient-id\nBCLI_SECRET\n", + ) + + assert result.exit_code == 0, result.stdout + config = load_config() + profile = config.get_profile("automation") + assert profile.auth_method == "client_credentials" + assert profile.client_secret_env == "BCLI_SECRET" + assert profile.company_id == "company-id" + assert captured["auth_method"] == "client_credentials" + + +def test_config_init_headless_uses_device_code(tmp_config): + result = runner.invoke( + app, + ["config", "init", "--profile", "ssh", "--headless"], + input="tenant-id\nSandbox\nclient-id\nn\n", + ) + + assert result.exit_code == 0, result.stdout + config = load_config() + profile = config.get_profile("ssh") + assert profile.auth_method == "device_code" + assert profile.client_secret_env is None + + +def test_auth_login_rejects_workos(tmp_config): + tmp_config.write_text( + '[defaults]\nprofile = "test"\n\n' + '[profiles.test]\ntenant_id = "t1"\nenvironment = "Sandbox"\n' + 'client_id = "client-id"\nauth_method = "browser"\n' + ) + state._config = None + + result = runner.invoke(app, ["auth", "login", "--method", "workos"]) + + assert result.exit_code == 1 + assert "Unsupported auth method 'workos'" in result.stdout diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py index d3b40b8..17d8de8 100644 --- a/tests/test_config/test_config.py +++ b/tests/test_config/test_config.py @@ -157,6 +157,26 @@ def test_load_config_global_file(monkeypatch, tmp_path): assert p.tenant_id == "t1" +def test_load_config_ignores_stale_workos_section(monkeypatch, tmp_path): + """Old configs may still contain [workos]; loading should not fail.""" + config_file = tmp_path / "config.toml" + config_file.write_text( + '[defaults]\nprofile = "test"\n\n' + '[workos]\napi_key = "old"\nclient_id = "old-client"\n\n' + '[profiles.test]\ntenant_id = "t1"\nenvironment = "Sandbox"\n' + 'auth_method = "browser"\nclient_id = "client-id"\n' + ) + monkeypatch.setattr("bcli.config._loader.CONFIG_FILE", config_file) + monkeypatch.setattr("bcli.config._loader._find_project_config", lambda: None) + monkeypatch.delenv("BCLI_PROFILE", raising=False) + monkeypatch.delenv("BCLI_FORMAT", raising=False) + monkeypatch.delenv("BCLI_TIMEOUT", raising=False) + + config = load_config() + + assert config.get_profile("test").auth_method == "browser" + + # ── save_config round-trip ──────────────────────────────────────────────── def test_save_config_round_trip(monkeypatch, tmp_path): diff --git a/uv.lock b/uv.lock index 4a940d7..d5f2f41 100644 --- a/uv.lock +++ b/uv.lock @@ -302,45 +302,33 @@ wheels = [ [[package]] name = "bc-cli" -version = "0.1.4" +version = "0.1.5" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "keyring" }, { name = "msal" }, { name = "pydantic" }, - { name = "tomlkit" }, -] - -[package.optional-dependencies] -cli = [ - { name = "keyring" }, { name = "pyyaml" }, { name = "rich" }, + { name = "tomlkit" }, { name = "typer" }, - { name = "workos" }, ] + +[package.optional-dependencies] dev = [ - { name = "keyring" }, + { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, { name = "mcp" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-httpx" }, - { name = "pyyaml" }, - { name = "rich" }, { name = "ruff" }, - { name = "typer" }, - { name = "workos" }, ] etl = [ { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, ] mcp = [ - { name = "keyring" }, { name = "mcp" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "typer" }, - { name = "workos" }, ] polaris = [ { name = "dlt", extra = ["filesystem", "parquet", "s3"] }, @@ -354,13 +342,12 @@ telemetry = [ [package.metadata] requires-dist = [ { name = "azure-monitor-opentelemetry", marker = "extra == 'telemetry'", specifier = ">=1.6" }, - { name = "bc-cli", extras = ["cli"], marker = "extra == 'dev'" }, - { name = "bc-cli", extras = ["cli"], marker = "extra == 'mcp'" }, + { name = "bc-cli", extras = ["etl"], marker = "extra == 'dev'" }, { name = "bc-cli", extras = ["etl"], marker = "extra == 'polaris'" }, { name = "bc-cli", extras = ["mcp"], marker = "extra == 'dev'" }, { name = "dlt", extras = ["parquet", "filesystem", "s3"], marker = "extra == 'etl'", specifier = ">=1.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "keyring", marker = "extra == 'cli'", specifier = ">=25.0" }, + { name = "keyring", specifier = ">=25.0" }, { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, { name = "msal", specifier = ">=1.28" }, { name = "pyarrow", marker = "extra == 'polaris'", specifier = ">=16.0" }, @@ -369,12 +356,11 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.30" }, - { name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0" }, - { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "tomlkit", specifier = ">=0.13" }, - { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12" }, - { name = "workos", marker = "extra == 'cli'", specifier = ">=5.0" }, + { name = "typer", specifier = ">=0.12" }, ] provides-extras = ["cli", "etl", "telemetry", "mcp", "polaris", "dev"] @@ -2880,21 +2866,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/19/7ea9a22a69fc23d5ca02e8edf65e4a335a210497794af1af0ef8fda91fa0/win_precise_time-1.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:85670f77cc8accd8f1e6d05073999f77561c23012a9ee988cbd44bb7ce655062", size = 14913, upload-time = "2023-10-08T17:08:10.677Z" }, ] -[[package]] -name = "workos" -version = "5.46.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/c7/8eb6f513a39a24544f28d0fd764db1c4a54b897885b9f0aede0b4a28d436/workos-5.46.0.tar.gz", hash = "sha256:db6ce0fa6924bfd7a790c2bdae97cf7be8a970eaaef04156ae554157d24376f2", size = 65693, upload-time = "2026-03-16T20:49:13.215Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/8f/43b2345c7d0283660db4c205e93134c4a57406ee896782e7384431906493/workos-5.46.0-py3-none-any.whl", hash = "sha256:b83704d4d6fcafa57439e232f62944b619aabc1c632b0ced9f71501298be0c78", size = 121032, upload-time = "2026-03-16T20:49:11.905Z" }, -] - [[package]] name = "wrapt" version = "1.17.3"