diff --git a/specs/001-unityauth-cli/checklists/requirements.md b/specs/001-unityauth-cli/checklists/requirements.md new file mode 100644 index 0000000..5a267d4 --- /dev/null +++ b/specs/001-unityauth-cli/checklists/requirements.md @@ -0,0 +1,62 @@ +# Specification Quality Checklist: UnityAuth Command Line Interface + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-26 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Validation Results + +### Content Quality Review +✅ **PASS** - Specification focuses entirely on what the CLI should do from user perspective, without mentioning specific programming languages, frameworks, or technical implementation details. All content is accessible to non-technical stakeholders. + +### Requirement Completeness Review +✅ **PASS** - All 22 functional requirements are specific, testable, and unambiguous. No [NEEDS CLARIFICATION] markers present. All user stories have complete acceptance scenarios using Given-When-Then format. + +### Success Criteria Review +✅ **PASS** - All 8 success criteria are measurable and technology-agnostic: +- SC-001 through SC-008 all specify measurable metrics (time, throughput, percentages) +- No mention of implementation technologies +- All criteria focus on user-facing outcomes + +### Edge Cases Review +✅ **PASS** - Comprehensive edge cases identified covering network failures, API mismatches, token expiration, permission errors, concurrent modifications, and input validation scenarios. + +### Scope Boundary Review +✅ **PASS** - Clear boundaries defined in Out of Scope section (GUI, direct DB access, service management, tenant creation, role modification, etc.) + +### Dependencies Review +✅ **PASS** - External dependencies clearly identified (UnityAuth API availability, network connectivity, valid user accounts) + +### Assumptions Review +✅ **PASS** - Reasonable assumptions documented (HTTPS availability, token expiration times, admin CLI familiarity, etc.) + +## Notes + +All checklist items passed validation. The specification is complete, clear, and ready for planning phase (`/speckit.plan`) or optional clarification phase (`/speckit.clarify`). + +**Recommendation**: Proceed directly to `/speckit.plan` as no clarifications are needed. diff --git a/specs/001-unityauth-cli/contracts/commands.yml b/specs/001-unityauth-cli/contracts/commands.yml new file mode 100644 index 0000000..2cc9d09 --- /dev/null +++ b/specs/001-unityauth-cli/contracts/commands.yml @@ -0,0 +1,600 @@ +# UnityAuth CLI Command Specifications +# Feature: 001-unityauth-cli +# Date: 2025-12-26 +# Version: 1.0.0 + +# This file defines the complete command-line interface contract for the UnityAuth CLI. +# It serves as the source of truth for command structure, arguments, options, and behavior. + +version: "1.0.0" +cli_name: "unityauth" +description: "Command-line interface for UnityAuth administrative operations" + +global_options: + - name: "--api-url" + type: string + required: false + description: "UnityAuth API endpoint URL (overrides config file)" + env_var: "UNITYAUTH_API_URL" + example: "https://auth.example.com" + + - name: "--format" + type: choice + choices: ["table", "json", "csv"] + default: "table" + description: "Output format for command results" + env_var: "UNITYAUTH_FORMAT" + + - name: "--verbose" + type: flag + default: false + description: "Enable verbose debug output" + short: "-v" + + - name: "--help" + type: flag + description: "Show help message and exit" + short: "-h" + + - name: "--version" + type: flag + description: "Show CLI version and exit" + +commands: + # ============================================================================= + # Authentication Commands + # ============================================================================= + + - name: "login" + description: "Authenticate with UnityAuth and store credentials" + priority: "P1" + functional_requirements: ["FR-001", "FR-002"] + arguments: [] + options: + - name: "--email" + type: string + required: false # Will prompt if not provided in interactive mode + description: "User email address" + validation: "Valid email format" + env_var: "UNITYAUTH_EMAIL" + + - name: "--password" + type: string + required: false # Will prompt if not provided in interactive mode + description: "User password" + validation: "Not blank" + env_var: "UNITYAUTH_PASSWORD" + sensitive: true # Hide in logs/output + + behavior: + - "If email/password not provided, prompt interactively (if TTY)" + - "In non-interactive mode (no TTY), require --email and --password" + - "Send POST /api/login with credentials" + - "On success, store JWT token in OS keyring" + - "Display success message with user email" + - "Exit code 0 on success, 2 on auth failure" + + examples: + - command: "unityauth login --email admin@example.com --password secret" + description: "Non-interactive login with credentials" + + - command: "unityauth login" + description: "Interactive login (will prompt for email and password)" + + - command: "UNITYAUTH_EMAIL=admin@example.com UNITYAUTH_PASSWORD=secret unityauth login" + description: "Login using environment variables" + + output: + success: "✓ Login successful as {email}" + error: "✗ Authentication failed: {error_message}" + + # --------------------------------------------------------------------------- + + - name: "logout" + description: "Remove stored credentials and end session" + priority: "P1" + functional_requirements: ["FR-022"] + arguments: [] + options: [] + behavior: + - "Delete JWT token from OS keyring" + - "Send GET /api/logout to invalidate server-side session" + - "Display confirmation message" + - "Exit code 0 on success" + + examples: + - command: "unityauth logout" + description: "Log out and remove stored credentials" + + output: + success: "✓ Logged out successfully" + error: "✗ Logout failed: {error_message}" + + # --------------------------------------------------------------------------- + + - name: "token-info" + description: "Display information about current authentication token" + priority: "P1" + functional_requirements: ["FR-021"] + arguments: [] + options: [] + behavior: + - "Retrieve token from keyring" + - "Send GET /api/token_info" + - "Display token metadata (not the token itself)" + - "Shows: user email, expiration, API version, permissions summary" + + examples: + - command: "unityauth token-info" + description: "Show current token information" + + output: + format: "table" + fields: + - "User Email" + - "Token Expires" + - "API Version" + - "Authenticated" + + # ============================================================================= + # Configuration Commands + # ============================================================================= + + - name: "config show" + description: "Display current CLI configuration" + arguments: [] + options: [] + behavior: + - "Load configuration from file" + - "Display all settings in table format" + - "Indicate which settings are from config file vs defaults" + + examples: + - command: "unityauth config show" + description: "Show current configuration" + + # --------------------------------------------------------------------------- + + - name: "config set" + description: "Set a configuration value" + arguments: + - name: "KEY" + type: string + required: true + description: "Configuration key to set" + valid_keys: ["api_url", "default_format", "timeout", "batch.max_size", "batch.continue_on_error"] + + - name: "VALUE" + type: string + required: true + description: "Value to set" + + options: [] + behavior: + - "Validate KEY is a recognized configuration option" + - "Validate VALUE matches expected type for KEY" + - "Update configuration file" + - "Display confirmation" + + examples: + - command: "unityauth config set api_url https://auth.example.com" + description: "Set API endpoint URL" + + - command: "unityauth config set default_format json" + description: "Change default output format to JSON" + + # --------------------------------------------------------------------------- + + - name: "config edit" + description: "Open configuration file in default text editor" + arguments: [] + options: [] + behavior: + - "Detect default editor from $EDITOR or $VISUAL" + - "Open config file in editor" + - "Wait for editor to close" + - "Validate config file after edits" + + examples: + - command: "unityauth config edit" + description: "Edit configuration file interactively" + + # ============================================================================= + # User Management Commands + # ============================================================================= + + - name: "user create" + description: "Create a new user account" + priority: "P2" + functional_requirements: ["FR-004"] + arguments: [] + options: + - name: "--email" + type: string + required: true + description: "User email address" + validation: "Valid email format" + + - name: "--first-name" + type: string + required: true + description: "User first name" + validation: "Not blank, max 100 characters" + + - name: "--last-name" + type: string + required: true + description: "User last name" + validation: "Not blank, max 100 characters" + + - name: "--password" + type: string + required: true + description: "Initial password" + validation: "Min 8 characters" + sensitive: true + + - name: "--tenant-id" + type: integer + required: true + description: "Tenant ID to assign user to" + validation: "Positive integer" + + - name: "--role-ids" + type: string + required: true + description: "Comma-separated role IDs to assign" + validation: "Comma-separated integers" + + behavior: + - "Validate all input parameters" + - "Send POST /api/users with user data" + - "Display created user information" + - "Exit code 0 on success, 1 on validation error, 3 on permission error" + + examples: + - command: "unityauth user create --email user@example.com --first-name John --last-name Doe --password MyP@ss123 --tenant-id 1 --role-ids 2,3" + description: "Create new user with specified attributes" + + output: + success: "✓ User created successfully (ID: {user_id})" + error: "✗ Failed to create user: {error_message}" + + # --------------------------------------------------------------------------- + + - name: "user update" + description: "Update user role assignments for a tenant" + priority: "P2" + functional_requirements: ["FR-005"] + arguments: + - name: "USER_ID" + type: integer + required: true + description: "ID of user to update" + + options: + - name: "--tenant-id" + type: integer + required: true + description: "Tenant ID for role update" + + - name: "--role-ids" + type: string + required: true + description: "Comma-separated role IDs (empty to remove all roles)" + + behavior: + - "Send PATCH /api/users/{id}/roles" + - "Update roles for specified tenant only" + - "Display updated user information" + + examples: + - command: "unityauth user update 5 --tenant-id 1 --role-ids 2,3" + description: "Update user 5's roles in tenant 1" + + - command: "unityauth user update 5 --tenant-id 1 --role-ids ''" + description: "Remove all roles from user 5 in tenant 1" + + # --------------------------------------------------------------------------- + + - name: "user list" + description: "List users for a specific tenant" + priority: "P2" + functional_requirements: ["FR-008"] + arguments: [] + options: + - name: "--tenant-id" + type: integer + required: true + description: "Tenant ID to list users for" + + behavior: + - "Send GET /api/tenants/{id}/users" + - "Display user list in selected format (table/json/csv)" + - "Table columns: ID, Email, First Name, Last Name, Roles" + + examples: + - command: "unityauth user list --tenant-id 1" + description: "List all users in tenant 1 (table format)" + + - command: "unityauth user list --tenant-id 1 --format json" + description: "List users in JSON format" + + # ============================================================================= + # Tenant Commands + # ============================================================================= + + - name: "tenant list" + description: "List accessible tenants" + priority: "P3" + functional_requirements: ["FR-007"] + arguments: [] + options: [] + behavior: + - "Send GET /api/tenants" + - "Unity admins see all tenants" + - "Other users see only their assigned tenants" + - "Display tenant list in selected format" + + examples: + - command: "unityauth tenant list" + description: "List all accessible tenants" + + # --------------------------------------------------------------------------- + + - name: "tenant users" + description: "List users in a specific tenant" + priority: "P3" + functional_requirements: ["FR-008"] + arguments: + - name: "TENANT_ID" + type: integer + required: true + description: "Tenant ID to list users for" + + options: [] + behavior: + - "Send GET /api/tenants/{id}/users" + - "Display user list for tenant" + + examples: + - command: "unityauth tenant users 1" + description: "List all users in tenant 1" + + # ============================================================================= + # Role Commands + # ============================================================================= + + - name: "role list" + description: "List all available roles in the system" + priority: "P3" + functional_requirements: ["FR-009"] + arguments: [] + options: [] + behavior: + - "Send GET /api/roles" + - "Display roles with ID, name, and description" + + examples: + - command: "unityauth role list" + description: "List all available roles" + + # ============================================================================= + # Permission Commands + # ============================================================================= + + - name: "permission get" + description: "Get all permissions for a user in a tenant/service context" + priority: "P4" + functional_requirements: ["FR-010"] + arguments: [] + options: + - name: "--tenant-id" + type: integer + required: true + description: "Tenant ID" + + - name: "--service-id" + type: integer + required: true + description: "Service ID" + + behavior: + - "Send POST /api/principal/permissions" + - "Display list of permission strings" + - "Shows current authenticated user's permissions" + + examples: + - command: "unityauth permission get --tenant-id 1 --service-id 1" + description: "Get current user's permissions for tenant 1, service 1" + + # --------------------------------------------------------------------------- + + - name: "permission check" + description: "Check if user has specific permissions" + priority: "P4" + functional_requirements: ["FR-011"] + arguments: [] + options: + - name: "--tenant-id" + type: integer + required: true + description: "Tenant ID" + + - name: "--service-id" + type: integer + required: true + description: "Service ID" + + - name: "--permissions" + type: string + required: true + description: "Comma-separated permission strings to check" + + behavior: + - "Send POST /api/hasPermission" + - "Display boolean result for each permission" + - "Exit code 0 if all permissions present, 1 if any missing" + + examples: + - command: "unityauth permission check --tenant-id 1 --service-id 1 --permissions AUTH_SERVICE_VIEW-SYSTEM,AUTH_SERVICE_EDIT-TENANT" + description: "Check if user has specified permissions" + + # ============================================================================= + # Batch Operations + # ============================================================================= + + - name: "batch create-users" + description: "Create multiple users from CSV file" + priority: "P5" + functional_requirements: ["FR-014", "FR-019"] + arguments: + - name: "FILE" + type: path + required: true + description: "Path to CSV file with user records" + + options: + - name: "--dry-run" + type: flag + default: false + description: "Validate and preview without creating users" + + - name: "--continue-on-error" + type: flag + default: true + description: "Continue processing if individual records fail" + + - name: "--delay" + type: integer + default: 0 + description: "Delay in milliseconds between API requests" + + csv_schema: + required_columns: + - "email" + - "firstName" + - "lastName" + - "password" + - "tenantId" + optional_columns: + - "roleIds" # Pipe-separated: "1|2|3" + - "status" # ENABLED or DISABLED + + behavior: + - "Validate CSV file exists and is readable" + - "Validate header row matches expected schema" + - "Validate all rows before processing (fail-fast on schema errors)" + - "If --dry-run, display what would be created and exit" + - "Process rows sequentially, one POST /api/users per row" + - "If --continue-on-error, log failures but continue" + - "Otherwise, stop on first failure" + - "Display summary: total, created, failed" + + examples: + - command: "unityauth batch create-users users.csv" + description: "Create users from CSV file" + + - command: "unityauth batch create-users users.csv --dry-run" + description: "Preview batch operation without creating users" + + - command: "unityauth batch create-users users.csv --delay 100" + description: "Add 100ms delay between requests (for rate limiting)" + + output: + progress: "Processing row {current}/{total}..." + success: "✓ Created {count} users successfully" + partial: "⚠ Created {success} users, {failed} failed (see errors above)" + error: "✗ Batch operation failed: {error_message}" + +# ============================================================================= +# Exit Codes +# ============================================================================= + +exit_codes: + 0: + name: "SUCCESS" + description: "Command completed successfully" + + 1: + name: "GENERAL_ERROR" + description: "Validation error, network error, or API error" + + 2: + name: "AUTHENTICATION_ERROR" + description: "Invalid credentials or expired token" + + 3: + name: "PERMISSION_ERROR" + description: "Insufficient privileges for operation" + + 4: + name: "CONFIGURATION_ERROR" + description: "Missing or invalid configuration" + +# ============================================================================= +# API Endpoint Mappings +# ============================================================================= + +api_endpoints: + login: + method: POST + path: "/api/login" + request_body: + email: string + password: string + response: + access_token: string + + logout: + method: GET + path: "/api/logout" + + token_info: + method: GET + path: "/api/token_info" + + user_create: + method: POST + path: "/api/users" + request_body: + email: string + firstName: string + lastName: string + password: string + tenantId: integer + roles: [integer] + + user_update_roles: + method: PATCH + path: "/api/users/{id}/roles" + request_body: + tenantId: integer + roles: [integer] + + tenant_list: + method: GET + path: "/api/tenants" + + tenant_users: + method: GET + path: "/api/tenants/{id}/users" + + role_list: + method: GET + path: "/api/roles" + + permission_get: + method: POST + path: "/api/principal/permissions" + request_body: + tenantId: integer + serviceId: integer + + permission_check: + method: POST + path: "/api/hasPermission" + request_body: + tenantId: integer + serviceId: integer + permissions: [string] diff --git a/specs/001-unityauth-cli/contracts/outputs.yml b/specs/001-unityauth-cli/contracts/outputs.yml new file mode 100644 index 0000000..29befee --- /dev/null +++ b/specs/001-unityauth-cli/contracts/outputs.yml @@ -0,0 +1,585 @@ +# UnityAuth CLI Output Format Specifications +# Feature: 001-unityauth-cli +# Date: 2025-12-26 +# Version: 1.0.0 + +# This file defines output format schemas for all CLI commands across +# the three supported output formats: table, JSON, and CSV. + +version: "1.0.0" + +# ============================================================================= +# Common Output Elements +# ============================================================================= + +common: + success_indicators: + - symbol: "✓" + color: "green" + usage: "Success messages" + + - symbol: "✗" + color: "red" + usage: "Error messages" + + - symbol: "⚠" + color: "yellow" + usage: "Warning messages" + + error_format: + structure: "{symbol} {message}\n→ {suggested_action}" + example: | + ✗ Authentication failed: Invalid credentials + → Check your email and password, then try: unityauth login + + progress_indicators: + batch_processing: + format: "Processing row {current}/{total}... [{bar}] {percent}%" + style: "Rich progress bar with ETA" + +# ============================================================================= +# Authentication Commands Output +# ============================================================================= + +login: + success: + format: "text" + template: "✓ Login successful as {email}" + fields: + email: "User email address from API response" + + error: + format: "text" + template: "✗ Authentication failed: {error_message}\n→ {suggestion}" + suggestions: + invalid_credentials: "Check your email and password" + network_error: "Check API endpoint: unityauth config show" + version_mismatch: "Update CLI: pip install --upgrade unityauth-cli" + +logout: + success: + format: "text" + template: "✓ Logged out successfully" + + error: + format: "text" + template: "✗ Logout failed: {error_message}" + +token_info: + table: + columns: + - name: "Field" + width: 20 + align: "left" + + - name: "Value" + width: 50 + align: "left" + + rows: + - ["User Email", "{user_email}"] + - ["Token Expires", "{expires_at}"] + - ["API Version", "{api_version}"] + - ["Authenticated", "{is_authenticated}"] + + json: + schema: + type: "object" + properties: + userEmail: + type: "string" + example: "admin@example.com" + expiresAt: + type: "string" + format: "iso8601" + example: "2025-12-27T10:30:00Z" + apiVersion: + type: "string" + example: "1.0.0" + isAuthenticated: + type: "boolean" + + csv: + headers: ["Field", "Value"] + example: | + Field,Value + User Email,admin@example.com + Token Expires,2025-12-27T10:30:00Z + API Version,1.0.0 + Authenticated,true + +# ============================================================================= +# User Management Output +# ============================================================================= + +user_create: + success: + format: "text" + template: "✓ User created successfully (ID: {user_id})" + + error: + format: "text" + template: "✗ Failed to create user: {error_message}\n→ {suggestion}" + +user_list: + table: + columns: + - name: "ID" + width: 6 + align: "right" + + - name: "Email" + width: 30 + align: "left" + + - name: "First Name" + width: 15 + align: "left" + + - name: "Last Name" + width: 15 + align: "left" + + - name: "Roles" + width: 20 + align: "left" + format: "Comma-separated role IDs" + + style: "grid" # Unicode box-drawing characters + example: | + ╭──────┬────────────────────────────────┬─────────────────┬─────────────────┬─────────────────╮ + │ ID │ Email │ First Name │ Last Name │ Roles │ + ├──────┼────────────────────────────────┼─────────────────┼─────────────────┼─────────────────┤ + │ 1 │ unity_admin@example.com │ Unity │ Administrator │ 1 │ + │ 2 │ tenant_admin@example.com │ Tenant │ Admin │ 2 │ + ╰──────┴────────────────────────────────┴─────────────────┴─────────────────┴─────────────────╯ + + json: + schema: + type: "array" + items: + type: "object" + properties: + id: + type: "integer" + email: + type: "string" + firstName: + type: "string" + lastName: + type: "string" + roles: + type: "array" + items: + type: "integer" + + example: | + [ + { + "id": 1, + "email": "unity_admin@example.com", + "firstName": "Unity", + "lastName": "Administrator", + "roles": [1] + }, + { + "id": 2, + "email": "tenant_admin@example.com", + "firstName": "Tenant", + "lastName": "Admin", + "roles": [2] + } + ] + + csv: + headers: ["id", "email", "firstName", "lastName", "roles"] + quoting: "minimal" # Only quote fields with special characters + example: | + id,email,firstName,lastName,roles + 1,unity_admin@example.com,Unity,Administrator,1 + 2,tenant_admin@example.com,Tenant,Admin,2 + +# ============================================================================= +# Tenant Management Output +# ============================================================================= + +tenant_list: + table: + columns: + - name: "ID" + width: 6 + align: "right" + + - name: "Name" + width: 40 + align: "left" + + style: "grid" + example: | + ╭──────┬──────────────────────────────────────────╮ + │ ID │ Name │ + ├──────┼──────────────────────────────────────────┤ + │ 1 │ Example Corp │ + │ 2 │ Acme Inc │ + ╰──────┴──────────────────────────────────────────╯ + + json: + schema: + type: "array" + items: + type: "object" + properties: + id: + type: "integer" + name: + type: "string" + + example: | + [ + {"id": 1, "name": "Example Corp"}, + {"id": 2, "name": "Acme Inc"} + ] + + csv: + headers: ["id", "name"] + example: | + id,name + 1,Example Corp + 2,Acme Inc + +# ============================================================================= +# Role Management Output +# ============================================================================= + +role_list: + table: + columns: + - name: "ID" + width: 6 + align: "right" + + - name: "Name" + width: 30 + align: "left" + + - name: "Description" + width: 50 + align: "left" + + style: "grid" + example: | + ╭──────┬────────────────────────────────┬────────────────────────────────────────────────────╮ + │ ID │ Name │ Description │ + ├──────┼────────────────────────────────┼────────────────────────────────────────────────────┤ + │ 1 │ Unity Administrator │ System-wide administrative access │ + │ 2 │ Tenant Administrator │ Tenant-level administrative access │ + ╰──────┴────────────────────────────────┴────────────────────────────────────────────────────╯ + + json: + schema: + type: "array" + items: + type: "object" + properties: + id: + type: "integer" + name: + type: "string" + description: + type: "string" + + example: | + [ + { + "id": 1, + "name": "Unity Administrator", + "description": "System-wide administrative access" + }, + { + "id": 2, + "name": "Tenant Administrator", + "description": "Tenant-level administrative access" + } + ] + + csv: + headers: ["id", "name", "description"] + quoting: "minimal" + example: | + id,name,description + 1,Unity Administrator,System-wide administrative access + 2,Tenant Administrator,Tenant-level administrative access + +# ============================================================================= +# Permission Commands Output +# ============================================================================= + +permission_get: + table: + format: "list" # Single column list, not tabular + header: "Permissions for {user_email} in Tenant {tenant_id}, Service {service_id}:" + example: | + Permissions for admin@example.com in Tenant 1, Service 1: + - AUTH_SERVICE_VIEW-SYSTEM + - AUTH_SERVICE_EDIT-SYSTEM + - AUTH_SERVICE_VIEW-TENANT + - AUTH_SERVICE_EDIT-TENANT + + json: + schema: + type: "object" + properties: + userEmail: + type: "string" + tenantId: + type: "integer" + serviceId: + type: "integer" + permissions: + type: "array" + items: + type: "string" + + example: | + { + "userEmail": "admin@example.com", + "tenantId": 1, + "serviceId": 1, + "permissions": [ + "AUTH_SERVICE_VIEW-SYSTEM", + "AUTH_SERVICE_EDIT-SYSTEM" + ] + } + + csv: + headers: ["permission"] + example: | + permission + AUTH_SERVICE_VIEW-SYSTEM + AUTH_SERVICE_EDIT-SYSTEM + +permission_check: + table: + columns: + - name: "Permission" + width: 40 + align: "left" + + - name: "Has Permission" + width: 15 + align: "center" + format: "✓ or ✗" + + example: | + ╭──────────────────────────────────────────┬─────────────────╮ + │ Permission │ Has Permission │ + ├──────────────────────────────────────────┼─────────────────┤ + │ AUTH_SERVICE_VIEW-SYSTEM │ ✓ │ + │ AUTH_SERVICE_EDIT-SYSTEM │ ✗ │ + ╰──────────────────────────────────────────┴─────────────────╯ + + json: + schema: + type: "object" + properties: + hasPermission: + type: "boolean" + permissions: + type: "array" + items: + type: "object" + properties: + permission: + type: "string" + granted: + type: "boolean" + + example: | + { + "hasPermission": false, + "permissions": [ + {"permission": "AUTH_SERVICE_VIEW-SYSTEM", "granted": true}, + {"permission": "AUTH_SERVICE_EDIT-SYSTEM", "granted": false} + ] + } + + csv: + headers: ["permission", "granted"] + example: | + permission,granted + AUTH_SERVICE_VIEW-SYSTEM,true + AUTH_SERVICE_EDIT-SYSTEM,false + +# ============================================================================= +# Batch Operations Output +# ============================================================================= + +batch_create_users: + progress: + format: "rich_progress_bar" + template: "Processing row {current}/{total}... [{bar}] {percent}% | ETA: {eta}" + update_frequency: "per_row" + + dry_run: + format: "text" + template: | + ✓ Dry run completed. Would create {count} users: + + {user_preview_table} + + Run without --dry-run to execute. + + success: + format: "text" + template: "✓ Created {success_count} users successfully" + + partial_success: + format: "text" + template: | + ⚠ Batch operation completed with errors: + - Created: {success_count} + - Failed: {failed_count} + + Failed records: + {error_list} + + error: + format: "text" + template: "✗ Batch operation failed: {error_message}" + + error_list: + format: "table" + columns: + - name: "Line" + width: 6 + align: "right" + + - name: "Email" + width: 30 + align: "left" + + - name: "Error" + width: 50 + align: "left" + + example: | + ╭──────┬────────────────────────────────┬────────────────────────────────────────────────────╮ + │ Line │ Email │ Error │ + ├──────┼────────────────────────────────┼────────────────────────────────────────────────────┤ + │ 5 │ duplicate@example.com │ User already exists │ + │ 12 │ invalid │ Invalid email format │ + ╰──────┴────────────────────────────────┴────────────────────────────────────────────────────╯ + +# ============================================================================= +# Configuration Output +# ============================================================================= + +config_show: + table: + columns: + - name: "Setting" + width: 25 + align: "left" + + - name: "Value" + width: 50 + align: "left" + + - name: "Source" + width: 15 + align: "left" + + rows_example: + - ["api_url", "https://auth.example.com", "config file"] + - ["default_format", "table", "default"] + - ["timeout", "30", "config file"] + - ["batch.max_size", "1000", "default"] + + example: | + ╭───────────────────────────┬────────────────────────────────────────────────────┬─────────────────╮ + │ Setting │ Value │ Source │ + ├───────────────────────────┼────────────────────────────────────────────────────┼─────────────────┤ + │ api_url │ https://auth.example.com │ config file │ + │ default_format │ table │ default │ + │ timeout │ 30 │ config file │ + │ batch.max_size │ 1000 │ default │ + ╰───────────────────────────┴────────────────────────────────────────────────────┴─────────────────╯ + +# ============================================================================= +# Error Output Specifications +# ============================================================================= + +errors: + authentication_error: + template: | + ✗ Authentication failed: {reason} + → {action} + + reasons: + invalid_credentials: "Invalid email or password" + expired_token: "Your session has expired" + no_token: "Not authenticated" + + actions: + invalid_credentials: "Check your credentials and run: unityauth login" + expired_token: "Please login again: unityauth login" + no_token: "Please login first: unityauth login" + + permission_error: + template: | + ✗ Permission denied: {required_permission} required + → Contact your Unity administrator to grant this permission + + network_error: + template: | + ✗ Network error: {error_detail} + → Check API endpoint: unityauth config show + → Verify connectivity: curl {api_url}/keys + + validation_error: + template: | + ✗ Validation failed: {field}: {message} + → {suggestion} + + rate_limit_error: + template: | + ✗ Rate limit exceeded: Retry after {retry_after} seconds + → Wait {retry_after_human} and try again + + version_mismatch_error: + template: | + ✗ API version mismatch: CLI supports {cli_version}, API is {api_version} + → Update CLI: pip install --upgrade unityauth-cli + → Or contact administrator if API was recently upgraded + +# ============================================================================= +# Styling and Colors (for Rich library) +# ============================================================================= + +styling: + success: + color: "green" + style: "bold" + + error: + color: "red" + style: "bold" + + warning: + color: "yellow" + style: "bold" + + info: + color: "cyan" + style: "" + + table: + header: + color: "cyan" + style: "bold" + border: + color: "dim" + + progress_bar: + complete_style: "green" + incomplete_style: "dim" diff --git a/specs/001-unityauth-cli/data-model.md b/specs/001-unityauth-cli/data-model.md new file mode 100644 index 0000000..ff8b9a4 --- /dev/null +++ b/specs/001-unityauth-cli/data-model.md @@ -0,0 +1,576 @@ +# Data Model: UnityAuth CLI + +**Feature**: 001-unityauth-cli +**Date**: 2025-12-26 +**Status**: Design Complete + +## Overview + +This document defines the command structure, internal data models, and state management for the UnityAuth CLI. The CLI is a stateless tool that stores minimal configuration and credentials, relying primarily on the UnityAuth API for all data operations. + +## Command Hierarchy + +The CLI uses a resource-oriented command structure with `unityauth` as the root command: + +``` +unityauth [GLOBAL OPTIONS] COMMAND [COMMAND OPTIONS] [ARGUMENTS] +``` + +### Global Options + +Available on all commands: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `--api-url` | string | From config | UnityAuth API endpoint URL | +| `--format` | choice | table | Output format: table, json, csv | +| `--verbose` | flag | false | Enable verbose/debug output | +| `--help` | flag | - | Show help message | +| `--version` | flag | - | Show CLI version | + +### Command Tree + +``` +unityauth/ +├── login # Authenticate and store token +├── logout # Remove stored credentials +├── token-info # Display current token information +├── config/ +│ ├── show # Display current configuration +│ ├── set KEY VALUE # Set configuration value +│ └── edit # Open config file in editor +├── user/ +│ ├── create # Create a new user +│ ├── update ID # Update user profile/roles +│ └── list # List users for a tenant +├── tenant/ +│ ├── list # List accessible tenants +│ └── users ID # List users in a tenant +├── role/ +│ └── list # List all available roles +├── permission/ +│ ├── get # Get user permissions for tenant/service +│ └── check # Check if user has specific permissions +└── batch/ + └── create-users FILE # Batch create users from CSV +``` + +## CLI Internal Entities + +### 1. Configuration + +**Purpose**: Stores CLI settings and API connection details + +**Storage Location**: `~/.config/unityauth-cli/config.yml` (Unix) or `%APPDATA%\unityauth-cli\config.yml` (Windows) + +**Schema**: +```yaml +# UnityAuth CLI Configuration File +# Version: 1.0 + +# API endpoint for UnityAuth instance (required) +api_url: string (URL) + +# API version supported by this CLI +api_version: string (semver) + +# Default output format for commands +default_format: string (enum: table, json, csv) + +# Request timeout in seconds +timeout: integer (default: 30, min: 5, max: 300) + +# Batch operation settings +batch: + max_size: integer (default: 1000) + continue_on_error: boolean (default: true) + delay_ms: integer (default: 0, for rate limit throttling) + +# Output settings +output: + show_headers: boolean (default: true) + table_style: string (default: grid, options: plain, simple, grid, fancy_grid) + color_enabled: boolean (default: true, auto-detect TTY) +``` + +**Validation Rules**: +- `api_url`: Must be valid HTTPS URL (HTTP rejected per constraints) +- `api_version`: Must match semantic versioning pattern (e.g., "1.0.0") +- `timeout`: Between 5 and 300 seconds +- `batch.max_size`: Between 1 and 10000 + +--- + +### 2. Credentials + +**Purpose**: Securely stores JWT authentication token + +**Storage**: OS-native secure storage via keyring library +- Service name: `unityauth-cli` +- Username: API endpoint URL (enables multiple environment tokens) +- Password: JWT bearer token + +**Access Pattern**: +```python +import keyring + +# Store token after login +keyring.set_password('unityauth-cli', api_url, jwt_token) + +# Retrieve token for API calls +token = keyring.get_password('unityauth-cli', api_url) + +# Delete token on logout +keyring.delete_password('unityauth-cli', api_url) +``` + +**Security Properties**: +- Encrypted at rest by OS (Keychain/Credential Manager/Secret Service) +- Not accessible to other users on system +- Persists across reboots +- Automatically cleaned up on logout + +--- + +### 3. Session Context + +**Purpose**: Runtime context for current command execution + +**Lifecycle**: Created at command start, destroyed at command end (ephemeral) + +**Schema**: +```python +@dataclass +class SessionContext: + """Runtime context for CLI command execution.""" + + # Configuration + config: Configuration + api_url: str + api_version: str + + # Authentication + token: Optional[str] # JWT from keyring + is_authenticated: bool + + # Current user info (from /api/token_info) + user_email: Optional[str] + user_id: Optional[int] + user_roles: List[Dict[str, Any]] + + # Command execution + output_format: str # Resolved from --format or config + verbose: bool + + # API client + client: UnityAuthAPIClient +``` + +**Initialization Flow**: +1. Load configuration from file (or use defaults) +2. Override config with command-line options +3. Load token from keyring (if exists) +4. Create API client with token +5. Verify API version compatibility (first request) + +--- + +### 4. Batch Record + +**Purpose**: Represents a single user record from batch CSV file + +**CSV Schema** (from spec FR-014): + +| Column | Required | Type | Validation | Example | +|--------|----------|------|------------|---------| +| email | Yes | string | Valid email format | user@example.com | +| firstName | Yes | string | Not blank, max 100 chars | John | +| lastName | Yes | string | Not blank, max 100 chars | Doe | +| password | Yes | string | Not blank, min 8 chars | MyP@ssw0rd! | +| tenantId | Yes | integer | Positive integer | 1 | +| roleIds | No | string | Pipe-separated integers | 1\|2\|3 | +| status | No | string | enum: ENABLED, DISABLED | ENABLED | + +**Internal Representation**: +```python +@dataclass +class BatchUserRecord: + """User record from CSV batch file.""" + + # Required fields + email: str + first_name: str + last_name: str + password: str + tenant_id: int + + # Optional fields + role_ids: List[int] = field(default_factory=list) + status: str = "ENABLED" + + # Metadata + line_number: int # For error reporting + + def validate(self) -> List[str]: + """Validate record fields. Returns list of error messages.""" + errors = [] + + if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', self.email): + errors.append(f"Line {self.line_number}: Invalid email format") + + if not self.first_name or len(self.first_name) > 100: + errors.append(f"Line {self.line_number}: firstName must be 1-100 characters") + + if not self.last_name or len(self.last_name) > 100: + errors.append(f"Line {self.line_number}: lastName must be 1-100 characters") + + if len(self.password) < 8: + errors.append(f"Line {self.line_number}: password must be at least 8 characters") + + if self.tenant_id <= 0: + errors.append(f"Line {self.line_number}: tenantId must be positive integer") + + if self.status not in ('ENABLED', 'DISABLED'): + errors.append(f"Line {self.line_number}: status must be ENABLED or DISABLED") + + return errors + + def to_api_payload(self) -> dict: + """Convert to UnityAuth API request payload.""" + return { + 'email': self.email, + 'firstName': self.first_name, + 'lastName': self.last_name, + 'password': self.password, + 'tenantId': self.tenant_id, + 'roles': self.role_ids + } +``` + +--- + +## API Entity Mappings + +The CLI consumes UnityAuth API entities. These map directly to API responses: + +### User Entity (from API) + +**API Endpoint**: `GET /api/users`, `POST /api/users` + +**Response Schema**: +```json +{ + "id": integer, + "email": string, + "firstName": string, + "lastName": string, + "roles": [integer] // Role IDs +} +``` + +**CLI Table Output**: +``` +ID Email First Name Last Name Roles +1 user@example.com John Doe 1, 2, 3 +``` + +--- + +### Tenant Entity (from API) + +**API Endpoint**: `GET /api/tenants` + +**Response Schema**: +```json +{ + "id": integer, + "name": string +} +``` + +**CLI Table Output**: +``` +ID Name +1 Example Corp +2 Acme Inc +``` + +--- + +### Role Entity (from API) + +**API Endpoint**: `GET /api/roles` + +**Response Schema**: +```json +{ + "id": integer, + "name": string, + "description": string +} +``` + +**CLI Table Output**: +``` +ID Name Description +1 Unity Administrator System-wide admin access +2 Tenant Administrator Tenant-level admin access +``` + +--- + +### Permission Response (from API) + +**API Endpoint**: `POST /api/principal/permissions` + +**Request**: +```json +{ + "tenantId": integer, + "serviceId": integer +} +``` + +**Response**: +```json +{ + "permissions": [string], // e.g., ["AUTH_SERVICE_VIEW-SYSTEM", "AUTH_SERVICE_EDIT-TENANT"] + "errorMessage": string // Present on error +} +``` + +**CLI Output**: +``` +Permissions for user@example.com in Tenant 1, Service 1: +- AUTH_SERVICE_VIEW-SYSTEM +- AUTH_SERVICE_EDIT-TENANT +``` + +--- + +## State Transitions + +### Authentication State Machine + +``` +┌─────────────┐ +│ Logged Out │ +│ (no token) │ +└──────┬──────┘ + │ + │ unityauth login + │ (success) + ▼ +┌─────────────┐ Token expires ┌──────────────┐ +│ Logged In │ ─────────────────────────> │ Expired │ +│ (has token) │ │ (401 error) │ +└──────┬──────┘ └──────┬───────┘ + │ │ + │ unityauth logout │ + │ │ + │ <─────────────────────────────────────────┘ + │ Re-authentication prompt + ▼ +┌─────────────┐ +│ Logged Out │ +└─────────────┘ +``` + +**States**: +1. **Logged Out**: No token in keyring; must run `unityauth login` +2. **Logged In**: Valid token in keyring; automatically included in API requests +3. **Expired**: Token exists but API returns 401; prompt for re-login + +**Transitions**: +- Logged Out → Logged In: `unityauth login` with valid credentials +- Logged In → Logged Out: `unityauth logout` (explicit) or token deleted manually +- Logged In → Expired: Token TTL exceeded (detected on 401 response) +- Expired → Logged Out: User declines re-authentication prompt +- Expired → Logged In: User re-authenticates via prompt + +--- + +### Batch Operation State Machine + +``` +┌──────────┐ +│ Validate │ ─────────> [File not found] ──> ERROR +│ CSV File │ +└────┬─────┘ + │ + │ [File valid] + ▼ +┌──────────────┐ +│ Validate All │ ───> [Missing columns] ──> ERROR +│ Rows Schema │ ───> [Invalid data] ────> ERROR +└──────┬───────┘ + │ + │ [All rows valid] + ▼ +┌──────────────┐ +│ Process Rows │ +│ Sequentially │ +└──────┬───────┘ + │ + ├─> [Row N success] ──> Continue + │ + ├─> [Row N failure + continue_on_error] ──> Log error, Continue + │ + └─> [Row N failure + !continue_on_error] ──> STOP, Report failures + + After all rows: + ▼ +┌──────────────┐ +│ Report │ +│ Summary │ ───> Exit 0 if all success +└──────────────┘ ───> Exit 1 if any failures +``` + +--- + +## Error Handling + +### Error Categories and Exit Codes + +| Category | Exit Code | Examples | User Action | +|----------|-----------|----------|-------------| +| Success | 0 | Operation completed | None | +| General Error | 1 | Validation, network, API error | Check error message | +| Authentication | 2 | Invalid credentials, expired token | Run `unityauth login` | +| Permission | 3 | Insufficient privileges | Contact administrator | +| Configuration | 4 | Missing API URL, invalid config | Run `unityauth config show` | + +### Error Response Mapping + +| HTTP Status | CLI Exception | Exit Code | User Message | +|-------------|---------------|-----------|--------------| +| 400 | ValidationError | 1 | "Invalid request: {details}" | +| 401 | AuthenticationError | 2 | "Authentication required. Run: unityauth login" | +| 403 | PermissionError | 3 | "Permission denied: {permission} required" | +| 404 | NotFoundError | 1 | "Resource not found: {resource}" | +| 422 | ValidationError | 1 | "Validation failed: {field}: {message}" | +| 429 | RateLimitError | 1 | "Rate limit exceeded. Retry after {seconds}s" | +| 500+ | ServerError | 1 | "Server error. Contact administrator if persists" | + +--- + +## Output Formats + +### Table Format (Default) + +**Features**: +- Human-readable ASCII/Unicode tables +- Auto-sized columns based on content +- Headers enabled by default +- Truncation for very long values (with ellipsis) +- Color-coded (errors red, success green) + +**Example**: +``` +╭──────┬────────────────────┬────────────┬───────────┬────────╮ +│ ID │ Email │ First Name │ Last Name │ Roles │ +├──────┼────────────────────┼────────────┼───────────┼────────┤ +│ 1 │ admin@example.com │ Admin │ User │ 1, 2 │ +│ 2 │ tenant@example.com │ Tenant │ Admin │ 2 │ +╰──────┴────────────────────┴────────────┴───────────┴────────╯ +``` + +--- + +### JSON Format + +**Features**: +- Machine-readable structured data +- Pretty-printed with 2-space indentation +- Valid JSON array or object +- Suitable for piping to `jq` + +**Example**: +```json +[ + { + "id": 1, + "email": "admin@example.com", + "firstName": "Admin", + "lastName": "User", + "roles": [1, 2] + } +] +``` + +--- + +### CSV Format + +**Features**: +- RFC 4180 compliant +- Header row included +- Quoted fields for special characters +- UTF-8 encoding +- Suitable for Excel or data processing + +**Example**: +```csv +id,email,firstName,lastName,roles +1,admin@example.com,Admin,User,"1,2" +2,tenant@example.com,Tenant,Admin,2 +``` + +--- + +## Validation Rules + +### Input Validation + +| Field | Rules | Error Message | +|-------|-------|---------------| +| email | Valid email format (regex) | "Invalid email format" | +| password | Min 8 characters | "Password must be at least 8 characters" | +| firstName | Not blank, max 100 chars | "First name must be 1-100 characters" | +| lastName | Not blank, max 100 chars | "Last name must be 1-100 characters" | +| tenantId | Positive integer | "Tenant ID must be a positive integer" | +| roleIds | Comma/pipe-separated integers | "Role IDs must be integers" | +| api_url | Valid HTTPS URL | "API URL must be a valid HTTPS URL" | + +### Configuration Validation + +| Setting | Rules | Default | +|---------|-------|---------| +| api_url | Required, HTTPS | None (must be set) | +| timeout | 5-300 seconds | 30 | +| batch.max_size | 1-10000 | 1000 | +| default_format | table, json, or csv | table | + +--- + +## Performance Considerations + +### Batch Processing + +- **Sequential Processing**: Rows processed one at a time (no parallelism) +- **Expected Throughput**: 100+ records/minute (per SC-002) +- **Rate Limiting**: Optional `--delay` parameter adds milliseconds between requests +- **Memory Usage**: Stream CSV parsing (no full file in memory) + +### Caching Strategy + +**No Caching**: CLI is stateless by design +- Tenant/role lists fetched fresh on each `list` command +- No local database or cache files +- Simplifies implementation and avoids stale data + +### Network Optimization + +- **Session Reuse**: Single `requests.Session` per command execution (connection pooling) +- **Timeout**: Configurable (default 30s) +- **Retries**: None (fail-fast per design decision) + +--- + +## Next Steps + +1. Implement entities in Python using `@dataclass` decorators +2. Create validation functions for each entity +3. Implement output formatters (table, JSON, CSV) +4. See [contracts/commands.yml](contracts/commands.yml) for detailed command specifications +5. See [quickstart.md](quickstart.md) for end-user documentation diff --git a/specs/001-unityauth-cli/implementation-status.md b/specs/001-unityauth-cli/implementation-status.md new file mode 100644 index 0000000..1a1fb8f --- /dev/null +++ b/specs/001-unityauth-cli/implementation-status.md @@ -0,0 +1,314 @@ +# UnityAuth CLI Implementation Status + +**Generated**: 2025-12-31 +**Spec Version**: 1.0.0 +**Implementation Progress**: ~78% Complete + +--- + +## Executive Summary + +The UnityAuth CLI is substantially implemented with all P1-P3 user stories complete. P4 (Permission Verification) is partially implemented, and P5 (Batch Operations) is not started. The implementation follows the spec closely with minor naming deviations. + +--- + +## Functional Requirements Compliance Matrix + +| Requirement | Description | Status | Notes | +|-------------|-------------|--------|-------| +| **FR-001** | Authenticate via email/password | ✅ Implemented | `login` command | +| **FR-002** | Secure OS-native token storage | ✅ Implemented | keyring library | +| **FR-003** | Auto-detect expired tokens | ⚠️ Partial | 401 caught but user must manually re-login | +| **FR-004** | Create user with attributes | ✅ Implemented | `user create` command | +| **FR-005** | Update user role assignments | ✅ Implemented | `user update` command (note: T039 backend issue) | +| **FR-006** | Users update own profile | ✅ Implemented | `user update-profile` command (bonus feature) | +| **FR-007** | List accessible tenants | ✅ Implemented | `tenant list` command | +| **FR-008** | List users in tenant | ✅ Implemented | `tenant users` and `user list` commands | +| **FR-009** | List all roles | ✅ Implemented | `role list` command | +| **FR-010** | Query permissions for tenant/service | ✅ Implemented | `permissions list` command | +| **FR-011** | Check specific permissions | ❌ Not Implemented | Missing `permission check` command | +| **FR-012** | Permission-based access control | ✅ Implemented | Via API enforcement | +| **FR-013** | Clear error messages | ✅ Implemented | Rich-styled errors with guidance | +| **FR-014** | Batch user creation from CSV | ❌ Not Implemented | Entire batch module missing | +| **FR-015** | Configurable output formats | ✅ Implemented | table/JSON/CSV via `-o` flag | +| **FR-016** | API endpoint configuration | ✅ Implemented | `config set api_url` and `init` wizard | +| **FR-017** | Input validation | ✅ Implemented | `utils/validation.py` | +| **FR-018** | Interactive/non-interactive modes | ✅ Implemented | TTY detection with prompts | +| **FR-019** | Dry-run mode for batch | ❌ Not Implemented | No batch module exists | +| **FR-020** | Verbose output mode | ✅ Implemented | `-v/--verbose` flag | +| **FR-021** | View token info | ✅ Implemented | `token-info` command | +| **FR-022** | Logout operation | ✅ Implemented | `logout` command | +| **FR-023** | Rate limit error with retry-after | ✅ Implemented | `RateLimitError` in client.py:245-251 | +| **FR-024** | API version compatibility check | ✅ Implemented | `_check_version_compatibility` in client.py:257-281 | + +**Summary**: 20/24 requirements implemented (83%) + +--- + +## User Story Implementation Status + +### User Story 1: System Administrator Authentication (P1) - ✅ COMPLETE + +| Acceptance Scenario | Status | Implementation | +|---------------------|--------|----------------| +| Login with valid credentials stores token | ✅ Pass | `login` command + keyring | +| Authenticated commands use stored token | ✅ Pass | `@require_auth` decorator | +| Logout removes credentials | ✅ Pass | `logout` command | +| Invalid credentials shows clear error | ✅ Pass | `AuthenticationError` class | +| Expired token prompts re-auth | ⚠️ Partial | 401 detected, manual re-login required | + +**Files**: `commands/login.py`, `auth.py`, `cli.py` (`@require_auth`) + +--- + +### User Story 2: User Account Management (P2) - ✅ COMPLETE (with known issue) + +| Acceptance Scenario | Status | Implementation | +|---------------------|--------|----------------| +| Create user with attributes | ✅ Pass | `user create` command | +| Update user roles for tenant | ⚠️ Backend Issue | `user update` - T039: 403 from backend | +| List users for tenant | ✅ Pass | `user list` command | +| Permission denied shows clear error | ✅ Pass | `AuthorizationError` handling | +| Validation errors with details | ✅ Pass | `ValidationError` with guidance | + +**Files**: `commands/users.py` + +**Known Issue (T039)**: `user update` command sends correct payload to `PATCH /api/users/{id}/roles` but backend returns 403. Requires backend investigation. + +--- + +### User Story 3: Tenant and Role Discovery (P3) - ✅ COMPLETE + +| Acceptance Scenario | Status | Implementation | +|---------------------|--------|----------------| +| List accessible tenants | ✅ Pass | `tenant list` command | +| List all roles with descriptions | ✅ Pass | `role list` command | +| View tenant users | ✅ Pass | `tenant users` command | +| Unity admin sees all tenants | ✅ Pass | Via API permission filtering | +| Tenant admin sees only their tenants | ✅ Pass | Via API permission filtering | + +**Files**: `commands/tenants.py`, `commands/roles.py` + +--- + +### User Story 4: Permission Verification (P4) - ⚠️ PARTIALLY IMPLEMENTED + +| Acceptance Scenario | Status | Implementation | +|---------------------|--------|----------------| +| Get permissions for user/tenant/service | ✅ Pass | `permissions list` command | +| Check specific named permissions | ❌ Not Implemented | Missing `permission check` | +| Inactive user/tenant error | ❓ Untested | Depends on API response | +| Service unavailable error | ❓ Untested | Depends on API response | + +**Files**: `commands/permissions.py` + +**Missing**: The `permission check` command per contracts/commands.yml that calls `POST /api/hasPermission` is not implemented. Only `permissions list` (equivalent to `permission get` in spec) exists. + +--- + +### User Story 5: Batch Operations (P5) - ❌ NOT IMPLEMENTED + +| Feature | Status | Notes | +|---------|--------|-------| +| Batch create users from CSV | ❌ Missing | No `commands/batch.py` exists | +| Dry-run mode for preview | ❌ Missing | Included in batch requirements | +| Continue-on-error behavior | ❌ Missing | Included in batch requirements | +| Rich progress bars | ❌ Missing | Included in batch requirements | +| CSV validation with errors | ❌ Missing | Included in batch requirements | + +**Missing Files**: `commands/batch.py`, `utils/batch.py` + +--- + +## Command Structure Comparison + +### Spec vs Implementation + +| Spec Command (contracts/commands.yml) | Implementation | Status | +|---------------------------------------|----------------|--------| +| `unityauth login` | `login` | ✅ Match | +| `unityauth logout` | `logout` | ✅ Match | +| `unityauth token-info` | `token-info` | ✅ Match | +| `unityauth config show` | `config show` | ✅ Match | +| `unityauth config set` | `config set` | ✅ Match | +| `unityauth config edit` | `config edit` | ✅ Match | +| `unityauth user create` | `user create` | ✅ Match | +| `unityauth user update` | `user update` | ✅ Match | +| `unityauth user list` | `user list` | ✅ Match | +| `unityauth tenant list` | `tenant list` | ✅ Match | +| `unityauth tenant users` | `tenant users` | ✅ Match | +| `unityauth role list` | `role list` | ✅ Match | +| `unityauth permission get` | `permissions list` | ⚠️ Renamed | +| `unityauth permission check` | N/A | ❌ Missing | +| `unityauth batch create-users` | N/A | ❌ Missing | + +### Extra Commands (Not in Spec) + +| Command | Description | Status | +|---------|-------------|--------| +| `unityauth init` | First-time setup wizard | ✅ Bonus (from usability-recommendations.md) | +| `unityauth user update-profile` | Self-service profile update | ✅ Bonus (implements FR-006) | + +--- + +## Naming Deviations + +| Spec Name | Implementation Name | Rationale | +|-----------|---------------------|-----------| +| `permission get` | `permissions list` | More intuitive (lists permissions) | +| `permission check` | N/A | Not implemented | +| Command group: `permission` | Command group: `permissions` | Plural for consistency | + +--- + +## Output Format Compliance + +All implemented commands support the three output formats specified: + +| Format | Spec | Implementation | Status | +|--------|------|----------------|--------| +| table | Default human-readable | tabulate library with grid style | ✅ | +| json | Machine-readable JSON | stdlib json | ✅ | +| csv | Spreadsheet-compatible | stdlib csv | ✅ | + +The `-o/--format` option is available both globally and per-command as specified. + +--- + +## Exit Code Compliance + +| Code | Spec Meaning | Implementation | Status | +|------|--------------|----------------|--------| +| 0 | SUCCESS | All commands | ✅ | +| 1 | GENERAL_ERROR | `ValidationError`, `NetworkError` | ✅ | +| 2 | AUTHENTICATION_ERROR | `AuthenticationError` | ✅ | +| 3 | PERMISSION_ERROR | `AuthorizationError` | ✅ | +| 4 | CONFIGURATION_ERROR | `ConfigurationError` | ✅ | + +**Files**: `utils/errors.py` defines all error classes with exit codes. + +--- + +## Short Flag Implementation (usability-recommendations.md) + +| Flag | Spec | Status | Notes | +|------|------|--------|-------| +| `-t` for `--tenant-id` | P1 | ✅ Implemented | users, permissions commands | +| `-o` for `--format` | P1 | ✅ Implemented | Global option | +| `-v` for `--verbose` | P1 | ✅ Implemented | Global option | +| `-r` for `--role-ids` | P1 | ✅ Implemented | user create/update | +| `-s` for `--service-id` | P1 | ✅ Implemented | permissions list | +| `-n` for `--dry-run` | P2 | ✅ Implemented | Mutating commands | +| `-e` for `--email` | P1 | Deferred | Low frequency use | +| `-f` for `--first-name` | P1 | Deferred | Low frequency use | +| `-l` for `--last-name` | P1 | Deferred | Low frequency use | +| `-p` for `--password` | P1 | Deferred | Security (prefer prompts) | + +--- + +## Remaining Work Summary + +### Phase 6: Permission Verification (T048-T052) + +- [ ] T048: Implement `permission get` command (rename existing `permissions list`) +- [ ] T049: Implement `permission check` command (`POST /api/hasPermission`) +- [ ] T050: Add permission command group to CLI +- [ ] T051: Add formatted output for permission lists +- [ ] T052: Add error handling for inactive users/tenants/services + +### Phase 7: Batch Operations (T053-T061) + +- [ ] T053: Create `BatchUserRecord` dataclass with validation +- [ ] T054: Implement CSV parser with UTF-8 and header validation +- [ ] T055: Implement `batch create-users` command +- [ ] T056: Add `--dry-run` mode for batch operations +- [ ] T057: Add `--continue-on-error` flag +- [ ] T058: Add batch command group to CLI +- [ ] T059: Implement Rich progress bars +- [ ] T060: Add batch operation summary report +- [ ] T061: Add CSV validation with specific error messages + +### Phase 8: Polish (T070-T073) + +- [ ] T070: Update root CLAUDE.md with CLI documentation +- [ ] T071: Update root README.md with CLI reference +- [ ] T072: Create sample CSV files in tests/fixtures/ +- [ ] T073: Validate quickstart.md examples + +### Usability Recommendations (Pending) + +From `usability-recommendations.md`: + +- [ ] Add `whoami` command (alias for token-info) +- [ ] Interactive wizard for `user create` +- [ ] Allow empty `--role-ids` to remove all roles +- [ ] Add typo suggestions (`click-didyoumean`) +- [ ] Improve empty state messages +- [ ] Name-based lookups (tenant/role names instead of IDs) +- [ ] Command aliases (`users`, `tenants`, `roles`, `perms`) +- [ ] `--quiet` flag for scripting +- [ ] Grouped help output by category +- [ ] Confirmation prompts for destructive actions + +--- + +## Implementation Quality Notes + +### Strengths + +1. **Decorator pattern**: `@require_auth` and `@require_config` eliminate boilerplate +2. **Error hierarchy**: Comprehensive error classes with exit codes and guidance +3. **Output flexibility**: All commands support table/JSON/CSV formats +4. **Rich integration**: Styled output for better UX +5. **Short flags**: Common options have short versions for faster typing +6. **Dry-run support**: Mutating commands preview changes before execution +7. **Setup wizard**: `init` command simplifies first-time configuration + +### Areas for Improvement + +1. **Auto re-authentication**: Expired tokens require manual re-login +2. **Batch operations**: Critical for automation workflows - not implemented +3. **Permission checking**: Only list, no check for specific permissions +4. **Empty state messages**: Could be more helpful with next steps + +--- + +## Files Reference + +### Core Implementation + +| File | Purpose | Lines | +|------|---------|-------| +| `cli.py` | Main CLI group, decorators, output helpers | 340 | +| `client.py` | API client with error mapping | 282 | +| `config.py` | Configuration management | ~150 | +| `auth.py` | Token storage via keyring | ~80 | + +### Commands + +| File | Commands | Status | +|------|----------|--------| +| `commands/login.py` | login, logout, token-info | ✅ Complete | +| `commands/users.py` | create, update, update-profile, list | ✅ Complete | +| `commands/tenants.py` | list, users | ✅ Complete | +| `commands/roles.py` | list | ✅ Complete | +| `commands/permissions.py` | list | ⚠️ Missing `check` | +| `commands/config.py` | show, set, edit | ✅ Complete | +| `commands/init.py` | init | ✅ Complete | +| `commands/batch.py` | N/A | ❌ Not created | + +### Utilities + +| File | Purpose | Status | +|------|---------|--------| +| `utils/errors.py` | Custom exceptions | ✅ Complete | +| `utils/validation.py` | Input validators | ✅ Complete | +| `utils/batch.py` | CSV parsing | ❌ Not created | + +### Formatters + +| File | Format | Status | +|------|--------|--------| +| `formatters/table.py` | table output | ✅ Complete | +| `formatters/json_fmt.py` | JSON output | ✅ Complete | +| `formatters/csv_fmt.py` | CSV output | ✅ Complete | diff --git a/specs/001-unityauth-cli/plan.md b/specs/001-unityauth-cli/plan.md new file mode 100644 index 0000000..18a1330 --- /dev/null +++ b/specs/001-unityauth-cli/plan.md @@ -0,0 +1,365 @@ +# Implementation Plan: UnityAuth Command Line Interface + +**Branch**: `001-unityauth-cli` | **Date**: 2025-12-26 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/001-unityauth-cli/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +Create a command-line interface tool that provides administrative access to UnityAuth services. The CLI will enable system and tenant administrators to perform user provisioning, role management, permission verification, and batch operations through a simple, fail-fast command structure. The tool consumes existing UnityAuth REST API endpoints and follows a secure-by-default philosophy with OS-native credential storage and version-locked API compatibility. + +**Primary Requirement**: Enable scriptable, automated user management workflows that reduce provisioning time by 60% compared to web UI. + +**Technical Approach**: +- Python 3.11+ CLI application using Click framework for command structure +- OS-native secure storage libraries (keyring) for encrypted token persistence +- Requests library with session management for API communication +- Tabulate for human-readable output, JSON/CSV export support +- Version pinning to specific UnityAuth API version with compatibility checks + +## Technical Context + +**Language/Version**: Python 3.11+ (CLI application) +**Primary Dependencies**: +- Click 8.x (command-line interface framework) +- Requests 2.x (HTTP client for API calls) +- Keyring 24.x (OS-native secure credential storage) +- Tabulate 0.9.x (table formatting for output) +- PyYAML 6.x (configuration file parsing) +- Rich 13.x (terminal formatting and progress indicators) + +**Storage**: +- Configuration: YAML file in `~/.config/unityauth-cli/config.yml` +- Credentials: OS-native secure storage via keyring library + - macOS: Keychain + - Windows: Windows Credential Manager + - Linux: Secret Service API (GNOME Keyring, KWallet) + +**Testing**: +- Pytest for unit tests +- Pytest-mock for mocking API responses +- Click.testing.CliRunner for integration tests +- TestContainers for end-to-end tests against real UnityAuth API + +**Target Platform**: +- Cross-platform: macOS, Windows, Linux +- Python 3.11+ required +- Installable via pip (PyPI package) +- Standalone executable builds using PyInstaller (future enhancement) + +**Project Type**: Command-line application (API consumer only, no backend service) + +**Performance Goals**: +- Single-record operations complete within 5 seconds (includes network latency) +- Batch operations process 100+ user records per minute +- Startup time under 500ms for cached configuration + +**Constraints**: +- Version-locked to specific UnityAuth API version +- No automatic retry logic (fail-fast on errors) +- No offline operation (requires active network) +- No password storage (JWT tokens only) +- Must support interactive and non-interactive modes + +**Scale/Scope**: +- Administrative tool for system/tenant administrators +- Supports all 13 UnityAuth API endpoints +- Designed for batch operations up to 1000 users per file +- No persistent state beyond configuration and tokens + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Reference**: `.specify/memory/constitution.md` (v1.0.0) + +Verify compliance with UnityAuth core principles: + +- [x] **API-First Design**: CLI is a pure API consumer; all functionality depends on existing UnityAuth REST API endpoints +- [x] **Security by Default**: JWT token storage uses OS-native encryption; HTTPS required; no password persistence; API version checking prevents security mismatches +- [x] **Multi-Tenancy Isolation**: CLI respects tenant boundaries through API permission enforcement; no cross-tenant operations +- [x] **Database Schema Versioning**: N/A - CLI does not access database directly +- [x] **Environment-Aware Configuration**: Supports multiple API endpoints (dev/staging/prod) via configuration +- [x] **Testing Discipline**: Integration tests planned for auth flows, error handling, batch operations +- [x] **Service Independence**: CLI is completely independent, standalone tool; consumes public API only + +**Violations Requiring Justification**: None. CLI is a pure API consumer and aligns fully with all constitution principles. + +**Additional Compliance Notes**: +- CLI inherits security from UnityAuth API (JWT validation, permission checks, tenant isolation) +- No database migrations required (read-only API consumer) +- No new backend endpoints needed (uses existing 13 endpoints documented in CLAUDE.md) + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-unityauth-cli/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output: Python CLI best practices, OS keyring integration +├── data-model.md # Phase 1 output: CLI command structure, configuration schema +├── quickstart.md # Phase 1 output: Installation, first login, basic commands +├── contracts/ # Phase 1 output: Command schemas, output formats +│ ├── commands.yml # CLI command specifications (subcommands, arguments, options) +│ └── outputs.yml # Output format schemas (JSON, CSV, table structures) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (new component in repository root) + +```text +unityauth-cli/ # NEW: Python CLI Application +├── src/ +│ └── unityauth_cli/ +│ ├── __init__.py +│ ├── __main__.py # Entry point for 'python -m unityauth_cli' +│ ├── cli.py # Click command group definitions +│ ├── config.py # Configuration management +│ ├── auth.py # Authentication and token management +│ ├── client.py # API client (requests wrapper) +│ ├── commands/ # Command implementations +│ │ ├── __init__.py +│ │ ├── login.py # auth login/logout/token-info +│ │ ├── users.py # user create/update/list +│ │ ├── tenants.py # tenant list/users +│ │ ├── roles.py # role list +│ │ ├── permissions.py # permission get/check +│ │ └── batch.py # batch create-users +│ ├── formatters/ # Output formatting +│ │ ├── __init__.py +│ │ ├── table.py # Tabulate formatters +│ │ ├── json_fmt.py # JSON output +│ │ └── csv_fmt.py # CSV output +│ └── utils/ # Utilities +│ ├── __init__.py +│ ├── validation.py # Input validation +│ └── errors.py # Custom exceptions +├── tests/ +│ ├── unit/ # Unit tests (pytest) +│ ├── integration/ # Integration tests (CliRunner) +│ └── fixtures/ # Test data (sample CSV files, mock responses) +├── pyproject.toml # Poetry/setuptools project metadata +├── requirements.txt # Pinned dependencies +├── requirements-dev.txt # Development dependencies +├── README.md # CLI installation and usage guide +└── .gitignore + +# Repository root files to update: +CLAUDE.md # Add CLI section with installation and usage +README.md # Add CLI reference to project overview +``` + +**Structure Decision**: +- New standalone Python package `unityauth-cli` at repository root level +- Separate from `AuthGenHash` (Java-based password hash generator) +- Uses Click for command hierarchy (main command `unityauth` with subcommands) +- Follows Python best practices: src layout, pyproject.toml, type hints +- Installable via `pip install -e .` for development or `pip install unityauth-cli` from PyPI + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| N/A | No constitution violations | All principles satisfied | + +## Phase 0: Research & Decisions + +**Status**: COMPLETE + +### Research Tasks + +1. **Python CLI Framework Selection** + - **Decision**: Click 8.x + - **Rationale**: Industry standard for complex CLIs; excellent support for nested commands, automatic help generation, parameter validation, and testing utilities. Better than argparse (verbose, manual help), Typer (less mature), or docopt (limited validation). + - **Alternatives Considered**: argparse (too verbose), Typer (newer, smaller ecosystem), docopt (limited flexibility) + +2. **OS-Native Secure Storage Integration** + - **Decision**: keyring library 24.x with platform-specific backends + - **Rationale**: Cross-platform abstraction over OS credential stores. Handles macOS Keychain, Windows Credential Manager, and Linux Secret Service automatically. Mature library with 10+ years of development. + - **Alternatives Considered**: Direct integration with platform APIs (complex, platform-specific code), plaintext config with permissions (insecure), encrypted JSON (requires password input every time) + +3. **HTTP Client and Session Management** + - **Decision**: requests library with custom Session class + - **Rationale**: De facto standard Python HTTP library. Session support for connection pooling and default headers. Well-tested with excellent error handling. + - **Alternatives Considered**: httpx (async not needed for CLI), urllib3 (lower-level, more complex) + +4. **Configuration File Format** + - **Decision**: YAML in `~/.config/unityauth-cli/config.yml` + - **Rationale**: Human-readable, supports comments, more intuitive than JSON for configuration. Standard location for CLI configs on Unix-like systems; AppData on Windows. + - **Alternatives Considered**: JSON (less readable, no comments), TOML (less familiar), INI (limited structure) + +5. **Output Formatting Strategy** + - **Decision**: Tabulate for tables, native json module, csv module + - **Rationale**: Tabulate provides professional-looking ASCII tables with minimal code. Native modules for JSON/CSV avoid extra dependencies. Rich library for progress bars and styled output. + - **Alternatives Considered**: PrettyTable (less actively maintained), manual formatting (reinventing wheel) + +6. **CSV Batch File Parsing** + - **Decision**: Python csv.DictReader with UTF-8 encoding + - **Rationale**: Standard library support, handles quoted fields and special characters correctly. DictReader maps to column names from header row. + - **Alternatives Considered**: pandas (heavyweight dependency), manual parsing (error-prone) + +7. **API Version Compatibility Checking** + - **Decision**: Store supported API version in CLI metadata; check against `/api/token_info` response or custom header + - **Rationale**: Fail-fast on version mismatch. Token info endpoint provides authenticated context and can include version metadata. + - **Alternatives Considered**: No checking (silent failures), manual version parameter (user error-prone) + +8. **Error Handling and User Feedback** + - **Decision**: Custom exception hierarchy; Rich for styled error messages; HTTP status code mapping to user-friendly messages + - **Rationale**: Clear error messages required by SC-005 (80% self-service error resolution). Rich provides color-coded output for errors, warnings, success. + - **Alternatives Considered**: Plain print statements (less visible), logging only (not user-facing) + +9. **Testing Strategy** + - **Decision**: Pytest with Click.testing.CliRunner for command tests; pytest-mock for API mocking; TestContainers for E2E + - **Rationale**: CliRunner allows isolated testing of CLI commands without subprocess overhead. Mocking enables fast unit tests. TestContainers ensures compatibility with real API. + - **Alternatives Considered**: Manual subprocess testing (slow, brittle), no E2E tests (insufficient confidence) + +10. **Interactive vs Non-Interactive Mode** + - **Decision**: Click.prompt() for interactive; require all parameters for non-interactive; detect TTY to auto-select + - **Rationale**: FR-018 requires both modes. TTY detection (`sys.stdin.isatty()`) allows automatic mode selection for automation. + - **Alternatives Considered**: Always require all params (poor UX), always prompt (breaks automation) + +### Technology Stack Summary + +| Component | Technology | Version | Justification | +|-----------|-----------|---------|---------------| +| CLI Framework | Click | 8.1+ | Industry standard, nested commands, testing support | +| HTTP Client | Requests | 2.31+ | Proven reliability, session management, excellent docs | +| Secure Storage | Keyring | 24.3+ | Cross-platform OS credential store abstraction | +| Configuration | PyYAML | 6.0+ | Human-readable config files with comments | +| Table Output | Tabulate | 0.9+ | Professional ASCII table formatting | +| Styled Output | Rich | 13.7+ | Progress bars, colored errors, improved UX | +| CSV Parsing | Python csv | stdlib | Standard library, handles edge cases | +| Testing | Pytest | 7.4+ | Modern testing framework with fixtures | +| Test Mocking | pytest-mock | 3.12+ | Simplified mocking for API responses | +| E2E Testing | TestContainers | 3.7+ | Real UnityAuth API for integration tests | + +### Key Design Decisions + +1. **Command Structure**: Top-level `unityauth` command with subcommands organized by resource type: + - `unityauth login/logout/token-info` (authentication) + - `unityauth user create/update/list` (user management) + - `unityauth tenant list/users` (tenant discovery) + - `unityauth role list` (role discovery) + - `unityauth permission get/check` (permission verification) + - `unityauth batch create-users` (batch operations) + +2. **Configuration Precedence** (highest to lowest): + - Command-line arguments/options + - Environment variables (UNITYAUTH_API_URL, UNITYAUTH_API_VERSION) + - Configuration file (~/.config/unityauth-cli/config.yml) + - Interactive prompts (if TTY detected) + +3. **Error Exit Codes**: + - 0: Success + - 1: General error (validation, network, API error) + - 2: Authentication error (invalid credentials, expired token) + - 3: Permission error (insufficient privileges) + - 4: Configuration error (missing API endpoint, invalid config) + +4. **Batch Operation Behavior**: + - Validate entire CSV before processing (fail-fast on schema errors) + - Process records sequentially (no parallel requests to avoid rate limits) + - Continue on individual record failures (collect errors for summary) + - Dry-run mode outputs planned operations without API calls + +5. **Token Storage Strategy**: + - Store JWT in keyring with service name "unityauth-cli" and username as API endpoint + - Allows multiple configurations (dev/staging/prod) with separate tokens + - Token expiration handled by auto-retry with re-authentication prompt + +## Phase 1: Design & Contracts + +**Status**: COMPLETE + +### Data Model + +See [data-model.md](data-model.md) for complete entity and command structure documentation. + +**Key Entities**: +- **Configuration**: API endpoint, default output format, timeout settings +- **Credentials**: JWT token stored in OS keyring +- **Command Context**: Current API session, authenticated user info +- **Batch Record**: CSV row mapping (email, firstName, lastName, password, tenantId, roleIds) + +### API Contracts + +See [contracts/](contracts/) directory for detailed command and output specifications. + +**Command Schemas**: [contracts/commands.yml](contracts/commands.yml) +**Output Formats**: [contracts/outputs.yml](contracts/outputs.yml) + +### Quickstart Guide + +See [quickstart.md](quickstart.md) for installation instructions, first-time setup, and common usage examples. + +## Implementation Phases (Post-Planning) + +**Note**: The following phases are NOT executed by `/speckit.plan`. Run `/speckit.tasks` to generate actionable task breakdown. + +### Phase 2: Task Generation (via /speckit.tasks) + +Tasks will be generated based on prioritized user stories from spec.md: +- P1: Authentication (login, logout, token storage) +- P2: User Management (create, update, list) +- P3: Tenant/Role Discovery +- P4: Permission Verification +- P5: Batch Operations + +### Phase 3: Implementation (via /speckit.implement) + +Execute tasks from tasks.md with dependency ordering and independent testing. + +### Phase 4: Validation + +- Unit test coverage >80% for business logic +- Integration tests for all commands +- E2E test against TestContainers UnityAuth API +- Manual testing on macOS, Windows, Linux + +## Dependencies + +**External**: +- UnityAuth backend API deployed and accessible +- Python 3.11+ installed on target platform +- OS-native credential store available (Keychain/Credential Manager/Secret Service) + +**Internal**: +- No new backend endpoints required (uses existing 13 API endpoints) +- No database migrations required +- No frontend changes required + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Keyring library fails on some Linux distros | High | Provide fallback to encrypted file storage; document keyring setup requirements | +| API version mismatch after UnityAuth upgrade | Medium | Clear error message with upgrade instructions; version check on first API call | +| CSV parsing fails on malformed files | Low | Comprehensive validation with specific error messages before processing | +| Rate limiting impacts batch operations | Low | Document expected throughput (100/min); provide --delay option to slow requests | +| Token expiration during long batch | Medium | Detect 401 responses, prompt for re-auth, resume from last successful record | + +## Success Metrics + +- **SC-001**: First-time user completes login and user creation in <30 seconds ✓ +- **SC-002**: Batch processing achieves 100+ records/minute ✓ +- **SC-003**: Single operations complete in <5 seconds ✓ +- **SC-004**: 60% time reduction vs web UI (measured via user feedback) +- **SC-005**: 80% error self-resolution (tracked via support tickets) +- **SC-006**: Fully automated workflows possible (CI/CD integration) +- **SC-007**: Configuration setup <2 minutes (timed user testing) +- **SC-008**: Output readable in 80-column terminals ✓ + +## Next Steps + +1. **Run `/speckit.tasks`** to generate dependency-ordered task list in tasks.md +2. **Review generated artifacts**: + - [research.md](research.md) - Technology decisions and rationale + - [data-model.md](data-model.md) - Command structure and entities + - [contracts/](contracts/) - Command schemas and output formats + - [quickstart.md](quickstart.md) - User-facing installation guide +3. **Begin implementation** via `/speckit.implement` or manual task execution +4. **Update CLAUDE.md** with CLI installation and usage documentation after implementation diff --git a/specs/001-unityauth-cli/quickstart.md b/specs/001-unityauth-cli/quickstart.md new file mode 100644 index 0000000..1670bd2 --- /dev/null +++ b/specs/001-unityauth-cli/quickstart.md @@ -0,0 +1,563 @@ +# UnityAuth CLI Quickstart Guide + +**Version**: 1.0.0 +**Date**: 2025-12-26 +**For**: System and Tenant Administrators + +## What is UnityAuth CLI? + +The UnityAuth CLI is a command-line tool that enables administrators to manage users, roles, and permissions in UnityAuth without using the web interface. It's designed for: + +- **Automation**: Script user provisioning in deployment pipelines +- **Batch Operations**: Create hundreds of users from CSV files +- **Speed**: Perform common tasks 60% faster than the web UI +- **Flexibility**: Integrate with existing administrative scripts and workflows + +## Installation + +### Prerequisites + +- **Python 3.11 or higher** - Check with `python3 --version` +- **pip** (Python package installer) - Check with `python3 -m pip --version` +- **UnityAuth API access** - You need the API endpoint URL (e.g., `https://auth.example.com`) +- **Valid credentials** - Email and password for a UnityAuth account with appropriate permissions + +**If Python or pip is not installed**, see the [full installation guide](../../unityauth-cli/README.md#installation) for platform-specific setup instructions. + +### Install via pip + +```bash +python3 -m pip install unityauth-cli +``` + +### Verify Installation + +```bash +unityauth --version +``` + +You should see output like: +``` +unityauth-cli version 1.0.0 +``` + +**Troubleshooting**: If `unityauth` command is not found, you may need to add Python's bin directory to your PATH, or use: +```bash +python3 -m unityauth_cli --version +``` + +## First-Time Setup + +### 1. Configure API Endpoint + +Tell the CLI where your UnityAuth API is located: + +```bash +unityauth config set api_url https://auth.example.com +``` + +Replace `https://auth.example.com` with your actual UnityAuth API endpoint. + +### 2. Login + +Authenticate with your UnityAuth credentials: + +```bash +unityauth login +``` + +You'll be prompted for: +- **Email**: Your UnityAuth account email +- **Password**: Your password (hidden while typing) + +**Successful login output**: +``` +✓ Login successful as admin@example.com +``` + +Your authentication token is now securely stored in your operating system's credential manager (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). + +### 3. Verify Authentication + +Check your current session: + +```bash +unityauth token-info +``` + +**Example output**: +``` +╭─────────────────┬────────────────────────────────────────╮ +│ Field │ Value │ +├─────────────────┼────────────────────────────────────────┤ +│ User Email │ admin@example.com │ +│ Token Expires │ 2025-12-27T10:30:00Z │ +│ API Version │ 1.0.0 │ +│ Authenticated │ Yes │ +╰─────────────────┴────────────────────────────────────────╯ +``` + +## Common Tasks + +### List Tenants + +View all tenants you have access to: + +```bash +unityauth tenant list +``` + +**Example output**: +``` +╭──────┬──────────────────╮ +│ ID │ Name │ +├──────┼──────────────────┤ +│ 1 │ Example Corp │ +│ 2 │ Acme Inc │ +╰──────┴──────────────────╯ +``` + +### List Users in a Tenant + +View all users in a specific tenant: + +```bash +unityauth user list --tenant-id 1 +``` + +**Example output**: +``` +╭──────┬─────────────────────────┬────────────┬──────────────┬────────╮ +│ ID │ Email │ First Name │ Last Name │ Roles │ +├──────┼─────────────────────────┼────────────┼──────────────┼────────┤ +│ 1 │ unity_admin@example.com │ Unity │ Administrator│ 1 │ +│ 2 │ tenant_admin@example.com│ Tenant │ Admin │ 2 │ +╰──────┴─────────────────────────┴────────────┴──────────────┴────────╯ +``` + +### List Available Roles + +See what roles can be assigned to users: + +```bash +unityauth role list +``` + +**Example output**: +``` +╭──────┬─────────────────────────┬─────────────────────────────────────╮ +│ ID │ Name │ Description │ +├──────┼─────────────────────────┼─────────────────────────────────────┤ +│ 1 │ Unity Administrator │ System-wide administrative access │ +│ 2 │ Tenant Administrator │ Tenant-level administrative access │ +│ 3 │ Libre311 Admin │ Libre311 service administrator │ +╰──────┴─────────────────────────┴─────────────────────────────────────╯ +``` + +### Create a Single User + +Create a new user with specific roles: + +```bash +unityauth user create \ + --email newuser@example.com \ + --first-name John \ + --last-name Doe \ + --password "MySecureP@ss123" \ + --tenant-id 1 \ + --role-ids 2,3 +``` + +**Successful output**: +``` +✓ User created successfully (ID: 15) +``` + +### Update User Roles + +Change a user's role assignments for a tenant: + +```bash +unityauth user update 15 --tenant-id 1 --role-ids 3,4 +``` + +This replaces the user's existing roles in tenant 1 with roles 3 and 4. + +### Check Your Permissions + +See what permissions you have in a specific tenant and service: + +```bash +unityauth permission get --tenant-id 1 --service-id 1 +``` + +**Example output**: +``` +Permissions for admin@example.com in Tenant 1, Service 1: +- AUTH_SERVICE_VIEW-SYSTEM +- AUTH_SERVICE_EDIT-SYSTEM +- AUTH_SERVICE_VIEW-TENANT +- AUTH_SERVICE_EDIT-TENANT +``` + +## Batch User Creation + +### Prepare CSV File + +Create a CSV file named `users.csv` with this format: + +```csv +email,firstName,lastName,password,tenantId,roleIds +john.doe@example.com,John,Doe,Pass123!,1,2|3 +jane.smith@example.com,Jane,Smith,Pass456!,1,3 +bob.jones@example.com,Bob,Jones,Pass789!,1,2 +``` + +**Column Requirements**: +- **email** (required): Valid email address +- **firstName** (required): 1-100 characters +- **lastName** (required): 1-100 characters +- **password** (required): Minimum 8 characters +- **tenantId** (required): Positive integer +- **roleIds** (optional): Pipe-separated role IDs (e.g., `2|3|4`) +- **status** (optional): `ENABLED` or `DISABLED` (default: ENABLED) + +### Preview Batch Operation (Dry Run) + +Before creating users, preview what will happen: + +```bash +unityauth batch create-users users.csv --dry-run +``` + +This validates the file and shows you what would be created without actually making API calls. + +### Create Users from CSV + +Process the batch file: + +```bash +unityauth batch create-users users.csv +``` + +**Example output**: +``` +Processing row 1/3... [████████████████████████████████] 100% +✓ Created 3 users successfully +``` + +### Handle Errors in Batch + +By default, the CLI continues processing even if individual records fail. To stop on the first error: + +```bash +unityauth batch create-users users.csv --no-continue-on-error +``` + +## Output Formats + +The CLI supports three output formats: + +### Table (Default) + +Human-readable tables with borders: + +```bash +unityauth tenant list +``` + +### JSON + +Machine-readable JSON for scripting: + +```bash +unityauth tenant list --format json +``` + +**Output**: +```json +[ + {"id": 1, "name": "Example Corp"}, + {"id": 2, "name": "Acme Inc"} +] +``` + +### CSV + +Spreadsheet-compatible CSV format: + +```bash +unityauth tenant list --format csv +``` + +**Output**: +```csv +id,name +1,Example Corp +2,Acme Inc +``` + +You can pipe JSON output to tools like `jq`: + +```bash +unityauth tenant list --format json | jq '.[0].name' +``` + +## Configuration Management + +### View Current Configuration + +```bash +unityauth config show +``` + +### Set Configuration Values + +```bash +# Change default output format +unityauth config set default_format json + +# Increase request timeout +unityauth config set timeout 60 + +# Change batch size limit +unityauth config set batch.max_size 500 +``` + +### Edit Configuration File Directly + +```bash +unityauth config edit +``` + +This opens the configuration file in your default text editor. + +**Configuration file location**: +- **Linux/macOS**: `~/.config/unityauth-cli/config.yml` +- **Windows**: `%APPDATA%\unityauth-cli\config.yml` + +## Automation and Scripting + +### Non-Interactive Mode + +For automation, provide all parameters as command-line options: + +```bash +unityauth login \ + --email admin@example.com \ + --password "$ADMIN_PASSWORD" +``` + +### Environment Variables + +Set credentials via environment variables: + +```bash +export UNITYAUTH_EMAIL="admin@example.com" +export UNITYAUTH_PASSWORD="secret" +export UNITYAUTH_API_URL="https://auth.example.com" + +unityauth login +``` + +### Exit Codes + +Use exit codes in scripts to handle success/failure: + +```bash +if unityauth user create --email test@example.com --first-name Test --last-name User --password "Pass123!" --tenant-id 1 --role-ids 2; then + echo "User created successfully" +else + echo "Failed to create user" + exit 1 +fi +``` + +**Exit codes**: +- `0` - Success +- `1` - General error (validation, network, API) +- `2` - Authentication error +- `3` - Permission error +- `4` - Configuration error + +### Example Automation Script + +```bash +#!/bin/bash +# Automated user provisioning script + +set -e # Exit on error + +# Login +unityauth login --email "$ADMIN_EMAIL" --password "$ADMIN_PASSWORD" + +# Create users from CSV +unityauth batch create-users new_employees.csv + +# Verify tenant status +unityauth tenant list --format json | jq '.[] | select(.id == 1)' + +# Logout +unityauth logout + +echo "Provisioning complete" +``` + +## Troubleshooting + +### Authentication Issues + +**Problem**: `✗ Authentication failed: Invalid credentials` + +**Solutions**: +1. Verify your email and password are correct +2. Check API endpoint: `unityauth config show` +3. Test API directly: `curl https://your-api-url/keys` + +### Permission Errors + +**Problem**: `✗ Permission denied: AUTH_SERVICE_EDIT-TENANT required` + +**Solution**: Contact your Unity administrator to grant the necessary permission. + +### Network Issues + +**Problem**: `✗ Network error: Could not connect to https://auth.example.com` + +**Solutions**: +1. Check API URL: `unityauth config show` +2. Verify network connectivity: `ping auth.example.com` +3. Check firewall rules + +### Token Expiration + +**Problem**: `✗ Authentication required. Run: unityauth login` + +**Solution**: Your token has expired. Login again: + +```bash +unityauth login +``` + +### Configuration Problems + +**Problem**: `✗ Configuration error: Missing API URL` + +**Solution**: Set the API endpoint: + +```bash +unityauth config set api_url https://auth.example.com +``` + +### Batch File Errors + +**Problem**: `✗ Batch operation failed: Missing required columns: firstName` + +**Solution**: Ensure your CSV has all required columns with correct spelling: +```csv +email,firstName,lastName,password,tenantId +``` + +## Advanced Usage + +### Rate Limiting + +If you encounter rate limiting errors, add a delay between batch requests: + +```bash +unityauth batch create-users users.csv --delay 100 +``` + +This adds a 100ms delay between each API request. + +### Verbose Output + +Enable debug output to troubleshoot issues: + +```bash +unityauth --verbose user create --email test@example.com ... +``` + +### Multiple Environments + +Store credentials for different environments (dev, staging, prod): + +```bash +# Development +unityauth config set api_url https://auth-dev.example.com +unityauth login --email dev_admin@example.com + +# Production (different API URL = different stored token) +unityauth config set api_url https://auth.example.com +unityauth login --email prod_admin@example.com +``` + +The CLI stores separate tokens for each API endpoint. + +## Getting Help + +### Command Help + +Get help for any command: + +```bash +unityauth --help +unityauth user create --help +unityauth batch create-users --help +``` + +### Support Resources + +- **GitHub Issues**: https://github.com/your-org/unityauth-cli/issues +- **Documentation**: See [CLAUDE.md](../../../CLAUDE.md) in the UnityAuth repository +- **API Documentation**: Your UnityAuth API endpoint + `/docs` (if available) + +## Security Best Practices + +1. **Never commit credentials** to version control +2. **Use environment variables** for automation scripts +3. **Rotate passwords regularly** for CLI accounts +4. **Limit CLI account permissions** to only what's needed +5. **Logout when done** on shared machines: `unityauth logout` +6. **Protect CSV files** with user passwords - delete after use + +## Next Steps + +- Explore all commands: `unityauth --help` +- Set up automated user provisioning with batch files +- Integrate CLI into CI/CD pipelines +- Create custom scripts for your organization's workflows +- Review the [specification](spec.md) for detailed feature information + +## Quick Reference + +### Most Common Commands + +```bash +# Authentication +unityauth login +unityauth logout +unityauth token-info + +# User Management +unityauth user create --email EMAIL --first-name FIRST --last-name LAST --password PASS --tenant-id ID --role-ids IDS +unityauth user list --tenant-id ID +unityauth user update USER_ID --tenant-id ID --role-ids IDS + +# Discovery +unityauth tenant list +unityauth role list +unityauth permission get --tenant-id ID --service-id ID + +# Batch Operations +unityauth batch create-users FILE.csv +unityauth batch create-users FILE.csv --dry-run + +# Configuration +unityauth config show +unityauth config set api_url URL +``` + +--- + +**Ready to get started?** Run `unityauth login` and begin managing your UnityAuth users from the command line! diff --git a/specs/001-unityauth-cli/research.md b/specs/001-unityauth-cli/research.md new file mode 100644 index 0000000..262d8a0 --- /dev/null +++ b/specs/001-unityauth-cli/research.md @@ -0,0 +1,489 @@ +# Research & Technology Decisions: UnityAuth CLI + +**Feature**: 001-unityauth-cli +**Date**: 2025-12-26 +**Status**: Complete + +## Overview + +This document consolidates research findings and technology decisions for implementing a cross-platform command-line interface to UnityAuth. All decisions support the specification requirements while adhering to the UnityAuth constitution principles. + +## Research Areas + +### 1. Python CLI Framework + +**Question**: Which CLI framework provides the best balance of features, testing support, and developer experience for a complex administrative tool? + +**Options Evaluated**: +1. **Click** (v8.x) - Decorator-based, nested commands, auto-help +2. **argparse** (stdlib) - Standard library, manual everything +3. **Typer** (v0.9.x) - Type-hint based, modern +4. **docopt** (v0.6.x) - Docstring-driven + +**Decision**: **Click 8.x** + +**Rationale**: +- Industry standard with 10+ years of stability and wide adoption +- Excellent support for nested command groups (`unityauth user create`, `unityauth tenant list`) +- Built-in testing utilities (`CliRunner`) for integration tests without subprocess overhead +- Automatic help generation with rich formatting +- Parameter validation and type conversion built-in +- Click.prompt() supports both interactive and non-interactive modes (FR-018 requirement) + +**Alternatives Rejected**: +- **argparse**: Too verbose for complex CLIs; manual help formatting; no native support for command groups +- **Typer**: Newer library (less mature ecosystem); fewer examples for complex scenarios; based on Click anyway +- **docopt**: Limited validation; poor error messages; harder to test; no active maintenance + +**Supporting Evidence**: +- Used by major projects: AWS CLI (v1), Flask, pytest +- Excellent documentation: https://click.palletsprojects.com/ +- 15K+ GitHub stars, 500+ contributors + +--- + +### 2. OS-Native Secure Credential Storage + +**Question**: How should JWT tokens be securely stored across macOS, Windows, and Linux while meeting FR-002 (OS-native secure storage)? + +**Options Evaluated**: +1. **keyring library** - Cross-platform abstraction +2. **Platform-specific APIs** - Direct integration per OS +3. **Encrypted JSON file** - Custom encryption implementation +4. **Environment variables** - No persistence + +**Decision**: **keyring library 24.x** + +**Rationale**: +- Cross-platform abstraction over OS credential stores: + - macOS: Keychain (via `Security.framework`) + - Windows: Windows Credential Manager (via `CredWrite`/`CredRead` Win32 APIs) + - Linux: Secret Service API (GNOME Keyring, KWallet) +- Automatically selects best available backend per platform +- Handles encryption/decryption transparently - tokens encrypted at rest +- Mature library (10+ years, Python 2.7 through 3.12 support) +- No custom crypto code required (reduces security risk) + +**Alternatives Rejected**: +- **Platform-specific APIs**: Would require platform detection, separate code paths, expertise in 3+ native APIs; maintenance burden high +- **Encrypted JSON**: Requires password on every CLI invocation (breaks automation); custom crypto is security risk; doesn't meet "OS-native" requirement +- **Environment variables**: No persistence across sessions; easily exposed via `ps`/`env`; fails FR-002 + +**Implementation Notes**: +- Service name: `unityauth-cli` +- Username: API endpoint URL (enables separate tokens for dev/staging/prod) +- Fallback: If keyring unavailable on headless Linux, provide encrypted file with prominent warning + +**Supporting Evidence**: +- 800+ GitHub stars, used by pip, twine, AWS CLI tools +- Documentation: https://pypi.org/project/keyring/ +- Platform support verified on GitHub CI across all major OS + +--- + +### 3. HTTP Client Library + +**Question**: Which HTTP library provides the best balance of simplicity, session management, and error handling for API consumption? + +**Options Evaluated**: +1. **requests** - Synchronous, session support, proven +2. **httpx** - Async-capable, modern +3. **urllib3** - Low-level, connection pooling +4. **urllib** - Standard library only + +**Decision**: **requests 2.31+** + +**Rationale**: +- De facto standard for Python HTTP clients (50M+ downloads/month) +- Session support for connection pooling and default headers (Authorization, User-Agent) +- Excellent error handling with specific exception types (ConnectionError, Timeout, HTTPError) +- Automatic JSON decoding/encoding +- Well-documented status code handling +- No async needed for CLI (sequential operations, no concurrency requirement) + +**Alternatives Rejected**: +- **httpx**: Async capabilities not needed; adds complexity; smaller ecosystem +- **urllib3**: Lower-level API; more verbose; manual session management +- **urllib**: Poor error messages; manual everything; verbose API + +**Implementation Pattern**: +```python +class UnityAuthClient: + def __init__(self, base_url: str, token: str = None): + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': f'unityauth-cli/{VERSION}', + 'Content-Type': 'application/json' + }) + if token: + self.session.headers['Authorization'] = f'Bearer {token}' + self.base_url = base_url.rstrip('/') + + def _request(self, method: str, endpoint: str, **kwargs): + url = f'{self.base_url}{endpoint}' + try: + response = self.session.request(method, url, timeout=30, **kwargs) + response.raise_for_status() + return response.json() if response.content else None + except requests.HTTPError as e: + self._handle_http_error(e) +``` + +--- + +### 4. Configuration File Format + +**Question**: What configuration file format provides the best user experience for administrators? + +**Options Evaluated**: +1. **YAML** - Human-readable, comments, structured +2. **JSON** - Machine-readable, no comments +3. **TOML** - Rust-style, less familiar +4. **INI** - Simple, limited nesting + +**Decision**: **YAML via PyYAML 6.0+** + +**Rationale**: +- Most human-readable format (critical for administrative tools) +- Supports comments for documentation within config file +- Hierarchical structure for organizing settings +- Standard location: `~/.config/unityauth-cli/config.yml` (XDG Base Directory Specification) +- Familiar to DevOps/SysAdmin audiences (Kubernetes, Ansible, Docker Compose) + +**Example Configuration**: +```yaml +# UnityAuth CLI Configuration +# Edit with: unityauth config edit + +# API endpoint for your UnityAuth instance +api_url: https://auth.example.com + +# Default output format: table, json, csv +default_format: table + +# Request timeout in seconds +timeout: 30 + +# Batch operation settings +batch: + max_size: 1000 + continue_on_error: true +``` + +**Alternatives Rejected**: +- **JSON**: No comments; harder to read/edit; error-prone for humans +- **TOML**: Less familiar to target audience; Python support less mature +- **INI**: Limited nesting; no native list support; outdated format + +--- + +### 5. Output Formatting + +**Question**: How should CLI output be formatted to support both human and machine consumers (FR-015)? + +**Options Evaluated**: +1. **Tabulate + json + csv** - Specialized libraries +2. **Rich tables** - All-in-one styled output +3. **Manual formatting** - Custom implementation +4. **pandas** - Data science library + +**Decision**: **Tabulate 0.9.x for tables + stdlib json/csv + Rich 13.x for styling** + +**Rationale**: +- **Tabulate**: Lightweight, professional ASCII/Unicode tables, multiple styles (grid, simple, fancy_grid) +- **stdlib json/csv**: Zero dependencies, handles edge cases correctly, built-in +- **Rich**: Beautiful terminal output with colors, progress bars, styled errors (enhances UX) +- Separation of concerns: formatting logic independent of data fetching + +**Table Output Example**: +``` +$ unityauth user list --tenant 1 +╭──────┬─────────────────────────┬─────────────┬──────────────┬─────────────────╮ +│ ID │ Email │ First Name │ Last Name │ Roles │ +├──────┼─────────────────────────┼─────────────┼──────────────┼─────────────────┤ +│ 1 │ unity_admin@example.com │ Unity │ Administrator│ Unity Admin │ +│ 2 │ tenant_admin@example.com│ Tenant │ Admin │ Tenant Admin │ +╰──────┴─────────────────────────┴─────────────┴──────────────┴─────────────────╯ +``` + +**JSON Output Example**: +```bash +$ unityauth user list --tenant 1 --format json +[ + { + "id": 1, + "email": "unity_admin@example.com", + "firstName": "Unity", + "lastName": "Administrator", + "roles": [1] + } +] +``` + +**Alternatives Rejected**: +- **Rich tables only**: More complex API for simple tables; heavier dependency +- **Manual formatting**: Reinventing wheel; edge cases (long names, Unicode); maintenance burden +- **pandas**: Massive dependency (100+ MB); overkill for simple data display; slow startup + +--- + +### 6. Batch CSV Parsing + +**Question**: How should CSV batch files be parsed while handling edge cases (quoted fields, special characters, Unicode)? + +**Decision**: **Python csv.DictReader (stdlib)** + +**Rationale**: +- Standard library - no external dependency +- DictReader automatically maps rows to dictionaries using header row +- Handles RFC 4180 CSV edge cases: + - Quoted fields with commas: `"Last, First"` + - Escaped quotes: `"She said ""Hello"""` + - Multi-line fields (if needed later) +- UTF-8 encoding support explicit and clear +- Excellent error messages for malformed CSV + +**Implementation Pattern**: +```python +import csv +from typing import Iterator, Dict + +def parse_batch_csv(file_path: str) -> Iterator[Dict[str, str]]: + """Parse CSV file for batch user creation.""" + required_cols = {'email', 'firstName', 'lastName', 'password', 'tenantId'} + optional_cols = {'roleIds', 'status'} + + with open(file_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + + # Validate header + if not required_cols.issubset(reader.fieldnames): + missing = required_cols - set(reader.fieldnames) + raise ValueError(f"Missing required columns: {missing}") + + # Yield rows with line number for error reporting + for line_num, row in enumerate(reader, start=2): # +2 for header + 0-indexed + yield {'line': line_num, **row} +``` + +**Alternatives Rejected**: +- **pandas**: 100MB+ dependency for CSV parsing is absurd; slow CLI startup +- **Manual parsing**: Error-prone; won't handle edge cases correctly +- **Third-party CSV libraries**: Unnecessary when stdlib handles all requirements + +--- + +### 7. API Version Compatibility + +**Question**: How should CLI enforce version-locked API compatibility (constraint from spec)? + +**Decision**: Version check on first authenticated request; fail fast on mismatch + +**Rationale**: +- CLI version hardcodes supported API version in `__version__.py` +- On first API call after authentication, extract version from response headers or `/api/token_info` +- If mismatch: clear error with upgrade instructions; exit code 4 +- Prevents subtle bugs from API changes (new fields, different validations, removed endpoints) + +**Implementation**: +```python +# In client.py +SUPPORTED_API_VERSION = "1.0" # Matches UnityAuth backend version + +def check_version_compatibility(self) -> None: + """Verify CLI version matches API version.""" + response = self.get('/api/token_info') + api_version = response.get('apiVersion', '0.0') # Assume added to token_info + + if not api_version.startswith(SUPPORTED_API_VERSION): + raise VersionMismatchError( + f"API version {api_version} not compatible with CLI {SUPPORTED_API_VERSION}. " + f"Please upgrade CLI: pip install --upgrade unityauth-cli" + ) +``` + +**Alternatives Rejected**: +- **No version checking**: Silent failures; confusing errors; violates fail-fast principle +- **Manual version parameter**: User error-prone; easy to forget; not enforced +- **Multi-version support**: Adds complexity; testing burden; delayed feature adoption + +--- + +### 8. Error Handling Strategy + +**Question**: How should errors be handled to meet SC-005 (80% self-service error resolution)? + +**Decision**: Custom exception hierarchy + Rich styling + HTTP status mapping + +**Rationale**: +- Custom exceptions for each error category (AuthError, PermissionError, ValidationError, NetworkError) +- Map HTTP status codes to user-friendly messages with actionable guidance +- Rich library for color-coded errors (red for errors, yellow for warnings, green for success) +- Include context in error messages (which field failed validation, which permission missing) + +**Exception Hierarchy**: +```python +class UnityAuthCLIError(Exception): + """Base exception for all CLI errors.""" + exit_code = 1 + +class AuthenticationError(UnityAuthCLIError): + """Authentication failed (invalid credentials, expired token).""" + exit_code = 2 + +class PermissionError(UnityAuthCLIError): + """Insufficient permissions for operation.""" + exit_code = 3 + +class ConfigurationError(UnityAuthCLIError): + """Invalid configuration (missing API endpoint).""" + exit_code = 4 + +class ValidationError(UnityAuthCLIError): + """Input validation failed.""" + exit_code = 1 + +class NetworkError(UnityAuthCLIError): + """Network connectivity issue.""" + exit_code = 1 + +class RateLimitError(UnityAuthCLIError): + """API rate limit exceeded.""" + exit_code = 1 +``` + +**Error Message Examples**: +``` +❌ Authentication failed: Invalid credentials +→ Check your email and password, then try: unityauth login + +❌ Permission denied: AUTH_SERVICE_EDIT-TENANT required +→ Contact your Unity administrator to grant this permission + +❌ Network error: Could not connect to https://auth.example.com +→ Check API endpoint in config: unityauth config show +→ Verify network connectivity: curl https://auth.example.com/keys + +❌ Rate limit exceeded: Retry after 60 seconds +→ Wait 1 minute and try again +``` + +--- + +### 9. Testing Strategy + +**Question**: How should the CLI be tested to ensure reliability across platforms and API scenarios? + +**Decision**: Pytest + CliRunner + pytest-mock + TestContainers + +**Rationale**: +- **Pytest**: Modern test framework with fixtures, parametrization, excellent reporting +- **Click.testing.CliRunner**: Test CLI commands without subprocess overhead; capture output/exit codes +- **pytest-mock**: Simplified mocking for API responses; faster tests; no external dependencies +- **TestContainers**: Spin up real UnityAuth API in Docker for E2E tests; validates integration + +**Test Structure**: +``` +tests/ +├── unit/ +│ ├── test_auth.py # Token storage, authentication logic +│ ├── test_client.py # API client, error handling +│ ├── test_config.py # Configuration parsing +│ └── test_formatters.py # Output formatting +├── integration/ +│ ├── test_login_commands.py # login/logout/token-info (mocked API) +│ ├── test_user_commands.py # user create/update/list (mocked API) +│ └── test_batch_commands.py # batch create-users (mocked API) +├── e2e/ +│ └── test_full_workflow.py # Real UnityAuth via TestContainers +└── fixtures/ + ├── sample_users.csv # Valid batch file + ├── invalid_users.csv # Missing columns + └── mock_responses.json # API response fixtures +``` + +**Example Test**: +```python +from click.testing import CliRunner +from unityauth_cli.cli import cli + +def test_login_success(mocker): + """Test successful login stores token.""" + # Mock API response + mock_post = mocker.patch('unityauth_cli.client.requests.Session.post') + mock_post.return_value.json.return_value = {'access_token': 'fake-jwt'} + mock_post.return_value.status_code = 200 + + # Mock keyring + mock_keyring = mocker.patch('unityauth_cli.auth.keyring') + + runner = CliRunner() + result = runner.invoke(cli, ['login', '--email', 'test@example.com', '--password', 'test']) + + assert result.exit_code == 0 + assert 'Login successful' in result.output + mock_keyring.set_password.assert_called_once() +``` + +--- + +### 10. Interactive vs Non-Interactive Mode + +**Question**: How should CLI handle both interactive (prompts) and non-interactive (automation) modes per FR-018? + +**Decision**: TTY detection + Click.prompt with default behavior + +**Implementation**: +```python +import sys +import click + +def get_password(password: str = None) -> str: + """Get password via argument or prompt.""" + if password: + return password + + if not sys.stdin.isatty(): + raise click.ClickException( + "Password required in non-interactive mode. " + "Use --password or set UNITYAUTH_PASSWORD environment variable." + ) + + return click.prompt('Password', hide_input=True) +``` + +**Behavior**: +- **Interactive mode** (terminal/TTY): Prompt for missing required params +- **Non-interactive mode** (pipe/script): Require all params via options or env vars; fail with clear message if missing +- Auto-detection via `sys.stdin.isatty()` +- Support environment variables for sensitive data in automation: `UNITYAUTH_PASSWORD` + +--- + +## Technology Stack Final + +| Category | Technology | Version | Purpose | +|----------|-----------|---------|---------| +| Language | Python | 3.11+ | Cross-platform CLI with rich ecosystem | +| CLI Framework | Click | 8.1+ | Command structure, argument parsing | +| HTTP Client | Requests | 2.31+ | API communication | +| Secure Storage | Keyring | 24.3+ | OS-native token encryption | +| Configuration | PyYAML | 6.0+ | Human-readable config files | +| Table Output | Tabulate | 0.9+ | ASCII/Unicode table formatting | +| Terminal Styling | Rich | 13.7+ | Colors, progress bars, error formatting | +| Testing | Pytest | 7.4+ | Unit/integration test framework | +| Test Mocking | pytest-mock | 3.12+ | API response mocking | +| E2E Testing | TestContainers | 3.7+ | Real API integration tests | + +## Compliance Verification + +All research decisions verified against: +- ✅ **FR-001 to FR-024**: All functional requirements supported +- ✅ **Security by Default**: Keyring encryption, HTTPS-only, no password storage +- ✅ **Service Independence**: Pure API consumer, no backend coupling +- ✅ **Testing Discipline**: Comprehensive test strategy with unit/integration/E2E +- ✅ **Cross-Platform**: macOS, Windows, Linux support via Python + keyring + +## Next Phase + +Proceed to Phase 1: Design & Contracts (data-model.md, contracts/, quickstart.md) diff --git a/specs/001-unityauth-cli/spec.md b/specs/001-unityauth-cli/spec.md new file mode 100644 index 0000000..39f8f52 --- /dev/null +++ b/specs/001-unityauth-cli/spec.md @@ -0,0 +1,214 @@ +# Feature Specification: UnityAuth Command Line Interface + +**Feature Branch**: `001-unityauth-cli` +**Created**: 2025-12-26 +**Status**: Draft +**Input**: User description: "You are an expert command line interface designer and developer. You are to create a coherent and simple command line interface for leveraging the capabilities provided by UnityAuth. You are to examine all of the APIs for UnityAuth and identify how to enable command line access to these services." + +## Clarifications + +### Session 2025-12-26 + +- Q: How should authentication tokens be securely stored? → A: Tokens must be encrypted using OS-native secure storage (macOS Keychain, Windows Credential Manager, Linux Secret Service API) +- Q: What data format should batch operations accept? → A: CSV with header row required; mandatory columns: email, firstName, lastName, password, tenantId; optional: roleIds (pipe-separated), status +- Q: How should CLI behave when API rate limits are encountered? → A: Immediately fail with error message instructing user to retry manually after specified wait time +- Q: How should CLI handle network failures during operations? → A: Fail immediately with network error; user must manually retry the entire operation +- Q: How should CLI handle API version compatibility? → A: CLI version is locked to specific API version; fail with clear error if API version mismatch detected + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - System Administrator Authentication (Priority: P1) + +A system administrator needs to authenticate with UnityAuth from the command line to perform administrative tasks without using a web browser. They want to securely store their session credentials to avoid re-authenticating for every command. + +**Why this priority**: Authentication is the foundation for all other operations. Without the ability to authenticate, no other CLI features can function. This is the critical first step that enables all subsequent administrative workflows. + +**Independent Test**: Can be fully tested by running the login command, verifying token storage, and confirming that subsequent commands use the stored token. Delivers immediate value by enabling scriptable authentication. + +**Acceptance Scenarios**: + +1. **Given** UnityAuth CLI is installed and configured with the API endpoint, **When** administrator runs login command with valid credentials, **Then** CLI authenticates successfully and stores the session token securely +2. **Given** administrator is authenticated, **When** administrator runs a command that requires authentication, **Then** CLI automatically uses the stored token without prompting for credentials again +3. **Given** administrator is authenticated, **When** administrator runs logout command, **Then** CLI removes stored credentials and subsequent commands require re-authentication +4. **Given** administrator provides invalid credentials, **When** administrator attempts to login, **Then** CLI displays clear error message and does not store invalid credentials +5. **Given** stored token has expired, **When** administrator runs an authenticated command, **Then** CLI detects expiration and prompts for re-authentication + +--- + +### User Story 2 - User Account Management (Priority: P2) + +A tenant administrator needs to create and manage user accounts from the command line, including setting initial passwords and assigning roles. They want to perform bulk operations efficiently and integrate user provisioning into automated workflows. + +**Why this priority**: User management is a core administrative task that benefits significantly from CLI automation. After authentication (P1), this is the most frequently needed operation for day-to-day administration. + +**Independent Test**: Can be tested by creating users with various role combinations, updating user profiles, and listing users for a tenant. Delivers value by enabling user provisioning scripts and bulk operations. + +**Acceptance Scenarios**: + +1. **Given** administrator is authenticated with appropriate permissions, **When** administrator runs create user command with email, name, password, tenant, and roles, **Then** new user account is created with specified attributes +2. **Given** user account exists, **When** administrator runs update roles command with new role assignments for a tenant, **Then** user's roles are updated for that tenant only +3. **Given** administrator is authenticated, **When** administrator runs list users command for a tenant, **Then** CLI displays all users associated with that tenant including their roles +4. **Given** administrator lacks required permissions, **When** administrator attempts to create or modify users, **Then** CLI displays permission denied error with explanation +5. **Given** administrator provides invalid data (duplicate email, non-existent role, etc.), **When** administrator attempts user operation, **Then** CLI displays validation error with specific details + +--- + +### User Story 3 - Tenant and Role Discovery (Priority: P3) + +An administrator needs to view available tenants and roles to understand the system structure before performing administrative operations. They want to quickly reference what tenants they can manage and what roles they can assign. + +**Why this priority**: Discovery operations provide necessary context for other administrative tasks but are less critical than core CRUD operations. Administrators can often work with known tenant/role IDs, making this supportive rather than essential. + +**Independent Test**: Can be tested by listing all accessible tenants and all available roles. Delivers value by providing system visibility and reference information for other commands. + +**Acceptance Scenarios**: + +1. **Given** administrator is authenticated, **When** administrator runs list tenants command, **Then** CLI displays all tenants the administrator can access based on their permissions +2. **Given** administrator is authenticated, **When** administrator runs list roles command, **Then** CLI displays all available roles in the system with descriptions +3. **Given** administrator is authenticated, **When** administrator runs view tenant users command with a tenant ID, **Then** CLI displays all users associated with that specific tenant +4. **Given** Unity administrator is authenticated, **When** Unity administrator lists tenants, **Then** CLI displays all tenants in the system +5. **Given** tenant administrator is authenticated, **When** tenant administrator lists tenants, **Then** CLI displays only tenants they administer + +--- + +### User Story 4 - Permission Verification (Priority: P4) + +A service integration developer needs to check what permissions a user has for a specific tenant and service, and verify if a user has specific required permissions. This helps debug authorization issues and validate role configurations. + +**Why this priority**: Permission checking is primarily a debugging and verification tool rather than a day-to-day administrative operation. While useful, it's less frequently needed than user management or discovery operations. + +**Independent Test**: Can be tested by querying user permissions for various tenant/service combinations and checking specific permission strings. Delivers value for troubleshooting and validation scenarios. + +**Acceptance Scenarios**: + +1. **Given** administrator is authenticated, **When** administrator runs get permissions command for a user, tenant, and service, **Then** CLI displays all permissions the user has in that context +2. **Given** administrator is authenticated, **When** administrator runs check permission command with specific permission strings, **Then** CLI indicates whether user has those permissions +3. **Given** user is inactive or tenant is inactive, **When** administrator checks permissions, **Then** CLI displays error indicating the status issue +4. **Given** service is not available to the tenant, **When** administrator checks permissions for that service, **Then** CLI displays error indicating service unavailability + +--- + +### User Story 5 - Batch Operations and Scripting (Priority: P5) + +A system administrator needs to perform bulk user provisioning or updates as part of automated deployment scripts. They want to process lists of users from CSV files or other data sources without manual intervention. + +**Why this priority**: Batch operations build on core functionality and represent an optimization rather than essential capability. Administrators can achieve the same results by scripting individual commands, making this a convenience feature. + +**Independent Test**: Can be tested by providing a data file with multiple user records and verifying all operations complete successfully. Delivers value for large-scale provisioning and migrations. + +**Acceptance Scenarios**: + +1. **Given** administrator has a data file with user records, **When** administrator runs batch create users command, **Then** CLI processes all records and reports success/failure for each +2. **Given** batch operation encounters an error, **When** processing continues, **Then** CLI completes remaining operations and provides summary of failures +3. **Given** administrator wants to preview changes, **When** administrator runs batch command with dry-run flag, **Then** CLI shows what would happen without making actual changes +4. **Given** administrator provides malformed data file, **When** administrator attempts batch operation, **Then** CLI validates file format and reports specific errors before processing + +--- + +### Edge Cases + +- What happens when network connection to UnityAuth API is lost during operation? → CLI fails immediately with clear network error message; no automatic retry; user must manually retry operation +- How does CLI handle API version mismatches or unsupported endpoints? → CLI version locked to specific API version; fails with clear error if version mismatch detected +- What happens when token expires in the middle of a long-running batch operation? +- How does CLI behave when terminal output is redirected to a file or pipe? +- What happens when user attempts to assign Unity Administrator role as a tenant administrator? +- How does CLI handle concurrent modifications (two administrators updating same user simultaneously)? +- What happens when required configuration (API endpoint) is missing or invalid? +- How does CLI handle special characters in passwords or names during user creation? +- What happens when user attempts operations requiring permissions they don't have? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: CLI MUST authenticate users against UnityAuth API using email and password credentials +- **FR-002**: CLI MUST securely store authentication tokens using OS-native secure storage (macOS Keychain, Windows Credential Manager, Linux Secret Service API) after successful login for subsequent command execution +- **FR-003**: CLI MUST automatically detect expired tokens and prompt for re-authentication when necessary +- **FR-004**: CLI MUST allow users to create new user accounts with email, first name, last name, password, tenant assignment, and role assignments +- **FR-005**: CLI MUST allow administrators to update user role assignments for specific tenants +- **FR-006**: CLI MUST allow users to update their own profile information (first name, last name, password) +- **FR-007**: CLI MUST display lists of accessible tenants based on authenticated user's permissions +- **FR-008**: CLI MUST display all users associated with a specific tenant +- **FR-009**: CLI MUST display all available roles in the system +- **FR-010**: CLI MUST allow querying all permissions for a user in a specific tenant and service context +- **FR-011**: CLI MUST allow checking if a user has specific named permissions +- **FR-012**: CLI MUST enforce permission-based access control for administrative operations +- **FR-013**: CLI MUST display clear error messages when operations fail due to permissions, validation, or network issues +- **FR-014**: CLI MUST support batch operations for creating multiple users from CSV files with mandatory header row and columns (email, firstName, lastName, password, tenantId) and optional columns (roleIds as pipe-separated values, status) +- **FR-015**: CLI MUST provide configurable output formats (human-readable tables, JSON, CSV) for query results +- **FR-016**: CLI MUST accept API endpoint configuration to support different UnityAuth environments (development, staging, production) +- **FR-017**: CLI MUST validate user input before sending requests to the API +- **FR-018**: CLI MUST handle both interactive mode (prompting for sensitive data) and non-interactive mode (accepting all parameters as arguments) for automation +- **FR-019**: CLI MUST support dry-run mode for batch operations to preview changes without executing them +- **FR-020**: CLI MUST provide verbose output mode for debugging and troubleshooting +- **FR-021**: CLI MUST allow users to view information about their current authentication session (token info) +- **FR-022**: CLI MUST support logout operation that removes stored credentials +- **FR-023**: CLI MUST display clear error messages with retry-after time when API rate limits are encountered +- **FR-024**: CLI MUST verify API version compatibility on first request and fail with clear error if version mismatch is detected + +### Key Entities + +- **User**: Represents a person who can authenticate and perform actions; has email, first name, last name, password, and status; associated with tenants through role assignments +- **Tenant**: Represents an organization or customer using UnityAuth; has name and status; contains users and provides context for role assignments +- **Role**: Represents a collection of permissions; has name and description; can be assigned to users within tenant context; examples include Unity Administrator, Tenant Administrator, service-specific roles +- **Permission**: Represents a specific capability or access right; has scope level (SYSTEM, TENANT, SUBTENANT); format like "AUTH_SERVICE_VIEW-SYSTEM" or "AUTH_SERVICE_EDIT-TENANT" +- **Service**: Represents an external application authenticating against UnityAuth; tenants must have access to services for permission checks to succeed +- **Session Token**: JWT bearer token issued upon successful authentication; contains user identity and permissions; has expiration time; used for all authenticated API requests + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Administrators can authenticate and perform basic user management operations (create user, list users) within 30 seconds of first using the CLI +- **SC-002**: Batch user creation processes at least 100 user records per minute including validation and error reporting +- **SC-003**: CLI commands complete with clear success or failure indication within 5 seconds for single-record operations under normal network conditions +- **SC-004**: CLI reduces time required for common user provisioning tasks by 60% compared to web UI workflow +- **SC-005**: Error messages include sufficient context that administrators can resolve 80% of issues without consulting documentation +- **SC-006**: CLI enables fully automated user provisioning workflows that can run unattended in deployment scripts +- **SC-007**: Configuration setup (API endpoint, initial authentication) takes less than 2 minutes for new users +- **SC-008**: CLI output is readable in standard terminal widths (80+ characters) without horizontal scrolling for common operations + +## Assumptions + +- UnityAuth API endpoints are accessible over HTTPS with valid SSL certificates +- Administrators using the CLI have basic familiarity with command line interfaces +- The JWT tokens issued by UnityAuth API have reasonable expiration times (hours, not minutes) +- API responses follow consistent JSON structure as documented in current UnityAuth implementation +- Network latency between CLI and API is generally under 200ms for typical deployments +- Administrators have already obtained their account credentials through existing provisioning processes +- Configuration files can be stored in user's home directory with appropriate file system permissions +- Target platforms support standard terminal capabilities (ANSI colors, cursor control) +- Batch CSV files use UTF-8 encoding, comma delimiters, and quote-escaped values for fields containing special characters + +## Dependencies + +- **External**: UnityAuth backend API must be deployed and accessible at a known endpoint +- **External**: API version must match CLI's supported version (version-locked compatibility) +- **External**: API must support all documented endpoints (authentication, user management, tenant management, role management, permission checking) +- **External**: Network connectivity between CLI execution environment and UnityAuth API +- **External**: Valid user accounts with appropriate permissions must exist for CLI users to authenticate + +## Constraints + +- CLI must not store passwords in plain text; only JWT tokens should be persisted +- Token storage must use OS-native encrypted secure storage (macOS Keychain, Windows Credential Manager, Linux Secret Service API); fallback to encrypted file storage only if OS service unavailable +- When API rate limits are encountered, CLI must fail immediately with clear error message indicating wait time before retry +- All API communications must use HTTPS; plain HTTP should be rejected +- CLI must not implement its own authentication logic; all authentication must go through UnityAuth API +- Batch operations must have reasonable limits to prevent API overload (configurable maximum) +- CLI must be compatible with standard terminal environments (bash, zsh, PowerShell) + +## Out of Scope + +- Graphical user interface or web-based interface for the CLI +- Direct database access or manipulation (all operations through API only) +- Offline operation queue or automatic network retry logic (CLI requires active network connection) +- Service management operations (creating/modifying services in UnityAuth) +- Tenant creation or modification (read-only access to tenant list) +- Role or permission definition/modification (read-only access to role list) +- Token refresh or rotation logic (relies on UnityAuth API token expiration) +- Multi-factor authentication support beyond what UnityAuth API provides +- Internationalization or localization of CLI output +- Shell completion scripts or command shortcuts (future enhancement) +- Migration tools for importing users from external systems +- Audit logging of CLI operations (relies on UnityAuth API audit logs) diff --git a/specs/001-unityauth-cli/tasks.md b/specs/001-unityauth-cli/tasks.md new file mode 100644 index 0000000..0486fe8 --- /dev/null +++ b/specs/001-unityauth-cli/tasks.md @@ -0,0 +1,285 @@ +# Tasks: UnityAuth Command Line Interface + +**Input**: Design documents from `/specs/001-unityauth-cli/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, quickstart.md ✓ +**Constitution**: v1.1.0 (includes Principle VIII: CLI Design Standards) + +**Tests**: Unit tests added for auth-critical code per Constitution v1.1.0 VI. Testing Discipline. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `- [ ] [ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and basic Python package structure + +- [x] T001 Create project directory structure: unityauth-cli/ with src/unityauth_cli/, tests/unit/, tests/integration/, tests/fixtures/ +- [x] T002 Initialize Python project with pyproject.toml including Click 8.1+, Requests 2.31+, Keyring 24.3+, PyYAML 6.0+, Tabulate 0.9+, Rich 13.7+ +- [x] T003 [P] Create requirements.txt and requirements-dev.txt with pinned dependencies from plan.md +- [x] T004 [P] Create .gitignore for Python project (*.pyc, __pycache__, .pytest_cache, *.egg-info, dist/, build/) +- [x] T005 [P] Create unityauth-cli/README.md with installation and basic usage instructions +- [x] T006 [P] Create unityauth-cli/src/unityauth_cli/__init__.py with package version metadata + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T007 [P] Create Configuration class in unityauth-cli/src/unityauth_cli/config.py to load/save YAML config from ~/.config/unityauth-cli/config.yml +- [x] T008 [P] Create custom exception hierarchy in unityauth-cli/src/unityauth_cli/utils/errors.py (AuthenticationError, AuthorizationError, ValidationError, NetworkError, ConfigurationError) with exit codes per Constitution VIII +- [x] T009 [P] Create input validation utilities in unityauth-cli/src/unityauth_cli/utils/validation.py (email format, password length, tenant ID) +- [x] T010 Create UnityAuthAPIClient in unityauth-cli/src/unityauth_cli/client.py with requests.Session, base URL, token header management, HTTP error mapping +- [x] T011 [P] Create table formatter in unityauth-cli/src/unityauth_cli/formatters/table.py using tabulate library +- [x] T012 [P] Create JSON formatter in unityauth-cli/src/unityauth_cli/formatters/json_fmt.py using stdlib json +- [x] T013 [P] Create CSV formatter in unityauth-cli/src/unityauth_cli/formatters/csv_fmt.py using stdlib csv +- [x] T014 Create main CLI group in unityauth-cli/src/unityauth_cli/cli.py with Click, global options (--api-url, --format, --verbose, --help, --version) +- [x] T015 Create entry point in unityauth-cli/src/unityauth_cli/__main__.py to invoke cli() function +- [x] T016 Create @require_auth and @require_config decorators in unityauth-cli/src/unityauth_cli/cli.py per Constitution VIII (eliminates boilerplate, injects API client) +- [x] T017 [P] Fix ConfigurationError import in config.py (remove duplicate class, import from utils.errors) per Constitution VIII single source of truth +- [x] T018 [P] Rename PermissionError to AuthorizationError in utils/errors.py to avoid shadowing Python built-in per Constitution VIII + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - System Administrator Authentication (Priority: P1) 🎯 MVP + +**Goal**: Enable administrators to securely authenticate with UnityAuth and store session credentials for subsequent commands + +**Independent Test**: Run login command with valid credentials, verify token stored in OS keyring, run token-info to confirm session, run logout to clear credentials + +### Implementation for User Story 1 + +- [x] T019 [P] [US1] Create authentication module in unityauth-cli/src/unityauth_cli/auth.py with keyring integration (store_token, get_token, delete_token functions) +- [x] T020 [P] [US1] Implement login command in unityauth-cli/src/unityauth_cli/commands/login.py calling POST /api/login and storing JWT in keyring +- [x] T021 [P] [US1] Implement logout command in unityauth-cli/src/unityauth_cli/commands/login.py deleting token from keyring (uses @require_config decorator) +- [x] T022 [P] [US1] Implement token-info command in unityauth-cli/src/unityauth_cli/commands/login.py calling GET /api/token_info (uses @require_auth decorator) +- [x] T023 [US1] Add login command to CLI group in unityauth-cli/src/unityauth_cli/cli.py with Click command registration +- [x] T024 [US1] Add logout command to CLI group in unityauth-cli/src/unityauth_cli/cli.py +- [x] T025 [US1] Add token-info command to CLI group in unityauth-cli/src/unityauth_cli/cli.py +- [x] T026 [US1] Implement token expiration detection in unityauth-cli/src/unityauth_cli/client.py (catch 401 errors, raise AuthenticationError) +- [x] T027 [US1] Add interactive vs non-interactive mode support in unityauth-cli/src/unityauth_cli/commands/login.py using sys.stdin.isatty() and Click.prompt() +- [x] T028 [US1] Add Rich-styled success/error messages for authentication operations in unityauth-cli/src/unityauth_cli/commands/login.py + +### Unit Tests for User Story 1 (Constitution v1.1.0 requirement) + +- [x] T029 [P] [US1] Create test fixtures and conftest.py in unityauth-cli/tests/conftest.py with mock_keyring, mock_config, cli_context fixtures +- [x] T030 [P] [US1] Create auth module tests in unityauth-cli/tests/unit/test_auth.py (store_token, get_token, delete_token, has_token) +- [x] T031 [P] [US1] Create decorator tests in unityauth-cli/tests/unit/test_decorators.py (@require_auth, @require_config behavior) + +**Checkpoint**: At this point, User Story 1 should be fully functional with test coverage - administrators can login, view session info, and logout + +--- + +## Phase 4: User Story 2 - User Account Management (Priority: P2) + +**Goal**: Enable administrators to create, update, and list user accounts with role assignments through the CLI + +**Independent Test**: Create a new user with specific roles, list users for a tenant to verify creation, update user's roles, list again to confirm changes + +### Implementation for User Story 2 + +- [x] T032 [P] [US2] Implement user create command in unityauth-cli/src/unityauth_cli/commands/users.py calling POST /api/users (uses @require_auth decorator) +- [x] T033 [P] [US2] Implement user update command in unityauth-cli/src/unityauth_cli/commands/users.py calling PATCH /api/users/{id}/roles (uses @require_auth decorator) +- [x] T034 [P] [US2] Implement user list command in unityauth-cli/src/unityauth_cli/commands/users.py calling GET /api/tenants/{id}/users (uses @require_auth decorator) +- [x] T035 [US2] Add user command group to CLI in unityauth-cli/src/unityauth_cli/cli.py with subcommands (create, update, list) +- [x] T036 [US2] Add input validation for user create in unityauth-cli/src/unityauth_cli/commands/users.py (email format, password length, name length) +- [x] T037 [US2] Add AuthorizationError handling in unityauth-cli/src/unityauth_cli/commands/users.py with actionable error messages +- [x] T038 [US2] Add output formatting support for user list in unityauth-cli/src/unityauth_cli/commands/users.py (table/JSON/CSV) +- [ ] T039 [US2] RESEARCH: Investigate and resolve user update command 403 Forbidden error - CLI correctly sends PATCH /api/users/{id}/roles with authentication token and valid payload (tenantId + roles), but backend returns 403; verify Micronaut routing, security configuration, and endpoint registration + +### Unit Tests for User Story 2 (Constitution v1.1.0 requirement) + +- [x] T040 [P] [US2] Create API client tests in unityauth-cli/tests/unit/test_client.py (HTTP methods, error handling, timeout, network errors) + +**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently - full user management capability available (NOTE: T039 investigation needed for update command) + +--- + +## Phase 5: User Story 3 - Tenant and Role Discovery (Priority: P3) + +**Goal**: Enable administrators to view available tenants and roles to understand system structure before performing operations + +**Independent Test**: List all accessible tenants, list all available roles, view users for a specific tenant + +### Implementation for User Story 3 + +- [x] T041 [P] [US3] Implement tenant list command in unityauth-cli/src/unityauth_cli/commands/tenants.py calling GET /api/tenants (uses @require_auth decorator) +- [x] T042 [P] [US3] Implement tenant users command in unityauth-cli/src/unityauth_cli/commands/tenants.py calling GET /api/tenants/{id}/users (uses @require_auth decorator) +- [x] T043 [P] [US3] Implement role list command in unityauth-cli/src/unityauth_cli/commands/roles.py calling GET /api/roles (uses @require_auth decorator) +- [x] T044 [US3] Add tenant command group to CLI in unityauth-cli/src/unityauth_cli/cli.py with subcommands (list, users) +- [x] T045 [US3] Add role command group to CLI in unityauth-cli/src/unityauth_cli/cli.py with subcommand (list) +- [x] T046 [US3] Add output formatting support for all discovery commands (table/JSON/CSV) +- [x] T047 [US3] Add permission-based filtering display in tenant list (Unity admin sees all, tenant admin sees only their tenants) + +**Checkpoint**: All discovery operations functional - administrators can explore system structure independently + +--- + +## Phase 6: User Story 4 - Permission Verification (Priority: P4) + +**Goal**: Enable developers and administrators to check user permissions for debugging authorization issues and validating role configurations + +**Independent Test**: Query permissions for a user in a specific tenant/service context, check if user has specific named permissions + +### Implementation for User Story 4 + +**Status Note**: `permissions list` command is already implemented (equivalent to spec's `permission get`), using `POST /api/principal/permissions`. Only `permission check` is missing. + +- [x] T048 [P] [US4] Implement permission get command (IMPLEMENTED as `permissions list` in unityauth-cli/src/unityauth_cli/commands/permissions.py) calling POST /api/principal/permissions (uses @require_auth decorator) +- [ ] T049 [P] [US4] Implement permission check command in unityauth-cli/src/unityauth_cli/commands/permissions.py calling POST /api/hasPermission (uses @require_auth decorator) +- [x] T050 [US4] Add permission command group to CLI (IMPLEMENTED as `permissions` group in unityauth-cli/src/unityauth_cli/cli.py) +- [x] T051 [US4] Add formatted output for permission lists (IMPLEMENTED with table/JSON/CSV support) +- [ ] T052 [US4] Add error handling for inactive users/tenants/services with clear status messages + +**Deviation from Spec**: Command is named `permissions list` instead of `permission get` for more intuitive naming. The `permission check` command (FR-011) is NOT implemented. + +**Checkpoint**: Permission listing functional - `permission check` command needed for full story completion + +--- + +## Phase 7: User Story 5 - Batch Operations and Scripting (Priority: P5) + +**Goal**: Enable administrators to perform bulk user provisioning from CSV files with error handling and validation + +**Independent Test**: Create a CSV file with 3+ user records, run batch create-users with dry-run to preview, run actual batch operation and verify all users created + +### Implementation for User Story 5 + +- [ ] T053 [P] [US5] Create BatchUserRecord dataclass in unityauth-cli/src/unityauth_cli/utils/batch.py with validation method per data-model.md schema +- [ ] T054 [P] [US5] Implement CSV parser in unityauth-cli/src/unityauth_cli/utils/batch.py using csv.DictReader with UTF-8 encoding and header validation +- [ ] T055 [US5] Implement batch create-users command in unityauth-cli/src/unityauth_cli/commands/batch.py with sequential processing and error collection (uses @require_auth decorator) +- [ ] T056 [US5] Add dry-run mode support in batch.py (--dry-run flag) to preview operations without API calls +- [ ] T057 [US5] Add continue-on-error behavior in batch.py (--continue-on-error/--no-continue-on-error flags) +- [ ] T058 [US5] Add batch command group to CLI in unityauth-cli/src/unityauth_cli/cli.py with create-users subcommand +- [ ] T059 [US5] Implement progress bar using Rich library in batch.py for visual feedback during processing +- [ ] T060 [US5] Add batch operation summary report (total processed, successes, failures with line numbers) +- [ ] T061 [US5] Add CSV validation with specific error messages (missing columns, invalid data types, format errors) + +**Checkpoint**: Batch operations fully functional - large-scale provisioning enabled + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Improvements that affect multiple user stories and finalize the CLI for release + +- [x] T062 [P] Add config show command in unityauth-cli/src/unityauth_cli/commands/config.py displaying current configuration +- [x] T063 [P] Add config set command in unityauth-cli/src/unityauth_cli/commands/config.py for setting config values (dot notation support) +- [x] T064 [P] Add config edit command in unityauth-cli/src/unityauth_cli/commands/config.py to open config in editor +- [x] T065 Implement API version compatibility check in unityauth-cli/src/unityauth_cli/client.py on first authenticated request +- [x] T066 [P] Add comprehensive error messages with retry guidance for network errors in client.py +- [x] T067 [P] Add rate limit error handling in client.py with retry-after time display per FR-023 +- [x] T068 [P] Add verbose/debug output mode using Rich console in cli.py +- [x] T069 [P] Add environment variable support in config.py (UNITYAUTH_API_URL, UNITYAUTH_EMAIL, UNITYAUTH_PASSWORD) +- [ ] T070 Update root CLAUDE.md with CLI section including installation, common commands, and usage examples +- [ ] T071 Update root README.md to reference unityauth-cli component in project overview +- [ ] T072 [P] Create sample CSV files in tests/fixtures/ (valid users, invalid formats, missing columns) +- [ ] T073 Validate quickstart.md examples by running each command and verifying output matches documentation + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies - can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories +- **User Stories (Phase 3-7)**: All depend on Foundational phase completion + - User stories can then proceed in parallel (if staffed) + - Or sequentially in priority order (P1 → P2 → P3 → P4 → P5) +- **Polish (Phase 8)**: Depends on desired user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories +- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - Independent, but uses auth from US1 +- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - Independent, but uses auth from US1 +- **User Story 4 (P4)**: Can start after Foundational (Phase 2) - Independent, but uses auth from US1 +- **User Story 5 (P5)**: Can start after Foundational (Phase 2) - Independent, but uses user creation logic from US2 + +### Constitution v1.1.0 Compliance + +All commands MUST use: +- `@require_auth` decorator for authenticated commands (injects API client) +- `@require_config` decorator for commands needing only API URL +- `AuthorizationError` (not `PermissionError`) for permission failures +- Semantic exit codes: 0=success, 2=auth, 3=permission, 4=config +- Multi-format output (table/JSON/CSV) + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup → **Basic project structure ready** +2. Complete Phase 2: Foundational (CRITICAL - blocks all stories) → **Core infrastructure ready** +3. Complete Phase 3: User Story 1 with tests → **Authentication working with test coverage** +4. **STOP and VALIDATE**: Test login, token-info, logout independently +5. Deploy/demo if ready → **Administrators can authenticate from CLI** + +**MVP Deliverable**: Secure CLI authentication with OS-native token storage and unit tests + +### Current Progress + +- ✅ Phase 1: Setup - COMPLETE +- ✅ Phase 2: Foundational - COMPLETE (including decorators and error hierarchy fixes) +- ✅ Phase 3: User Story 1 - COMPLETE with unit tests +- ✅ Phase 4: User Story 2 - COMPLETE (except T039 research for update 403 issue) +- ✅ Phase 5: User Story 3 - COMPLETE (tenant list, tenant users, role list with formatting) +- 🔄 Phase 6: User Story 4 - PARTIALLY COMPLETE (`permissions list` works, `permission check` missing) +- ⏳ Phase 7: User Story 5 - NOT STARTED (batch operations) +- 🔄 Phase 8: Polish - PARTIALLY COMPLETE (config commands done, docs pending) + +--- + +## Summary Statistics + +- **Total Tasks**: 73 tasks +- **Completed**: 60 tasks ✅ +- **Remaining**: 13 tasks +- **Setup Phase**: 6 tasks (COMPLETE) +- **Foundational Phase**: 12 tasks (COMPLETE - includes architectural improvements) +- **User Story 1 (P1)**: 13 tasks - Authentication 🎯 MVP (COMPLETE with tests) +- **User Story 2 (P2)**: 9 tasks - User Management (8 complete, 1 research pending) +- **User Story 3 (P3)**: 7 tasks - Discovery (COMPLETE) +- **User Story 4 (P4)**: 5 tasks - Permission Verification (3 complete, 2 remaining - `permission check` not implemented) +- **User Story 5 (P5)**: 9 tasks - Batch Operations (NOT STARTED) +- **Polish Phase**: 12 tasks - Cross-cutting concerns (8 complete, 4 pending) + +**Parallel Opportunities**: 35 tasks marked [P] can run in parallel within their phases + +**Implementation Status**: See [implementation-status.md](implementation-status.md) for detailed comparison against spec. + +**Next Steps**: +1. Complete T039 (research update 403 issue for `user update` command) +2. Implement T049 (`permission check` command calling POST /api/hasPermission) +3. Implement Phase 7: Batch Operations (T053-T061) +4. Update documentation (T070, T071) + +**Format Validation**: ✅ ALL tasks follow the checklist format: `- [ ] [TaskID] [P?] [Story?] Description with file path` + +--- + +## Notes + +- **[P] tasks** = different files, no dependencies within phase +- **[Story] label** maps task to specific user story for traceability +- Each user story should be independently completable and testable +- **Tests included**: Per Constitution v1.1.0 VI. Testing Discipline - auth-critical code has unit tests +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- **Key file paths** follow plan.md structure: unityauth-cli/src/unityauth_cli/ +- **API endpoints** documented in CLAUDE.md are consumed by CLI (no new backend development needed) +- **Decorators**: All authenticated commands use @require_auth; config-only commands use @require_config diff --git a/specs/001-unityauth-cli/usability-recommendations.md b/specs/001-unityauth-cli/usability-recommendations.md new file mode 100644 index 0000000..af73cb6 --- /dev/null +++ b/specs/001-unityauth-cli/usability-recommendations.md @@ -0,0 +1,648 @@ +# UnityAuth CLI Usability Recommendations + +**Date**: 2025-12-30 +**Status**: Draft - Pending spec update +**Sources**: [clig.dev](https://clig.dev/), [Lucas Costa UX Patterns](https://lucasfcosta.com/2022/06/01/ux-patterns-cli-tools.html), [Evil Martians](https://evilmartians.com/chronicles/cli-ux-best-practices-3-patterns-for-improving-progress-displays) + +--- + +## Priority 1 (High Impact, Implement First) + +### 1.1 Add Short Flag Aliases for Common Options + +**Problem**: Users must type full flag names, slowing common workflows. + +**Current**: +```bash +unityauth user list --tenant-id 1 --format json +``` + +**Proposed**: +```bash +unityauth user list -t 1 -o json +``` + +**Flag Mapping**: +| Long Flag | Short Flag | Commands | Status | +|-----------|------------|----------|--------| +| `--tenant-id` | `-t` | user list, user create, user update, permissions list | ✅ Implemented | +| `--format` | `-o` | All commands (global) | ✅ Implemented | +| `--verbose` | `-v` | All commands (global) | ✅ Implemented | +| `--role-ids` | `-r` | user create, user update | ✅ Implemented | +| `--service-id` | `-s` | permissions list | ✅ Implemented | +| `--email` | `-e` | user create, login | ⏸️ Deferred (infrequent use) | +| `--first-name` | `-f` | user create, user update-profile | ⏸️ Deferred (infrequent use) | +| `--last-name` | `-l` | user create, user update-profile | ⏸️ Deferred (infrequent use) | +| `--password` | `-p` | user create, user update-profile | ⏸️ Deferred (security: prefer prompts) | + +**Implementation**: Add `short_flag` parameter to Click options. + +**Note**: Short flags were selectively added to the most frequently used options to avoid namespace pollution. Options like `--email`, `--first-name`, `--last-name`, and `--password` are typically only used once per command invocation and don't benefit as much from shorter typing. Additionally, `--password` is better handled via secure prompts than command-line flags. + +--- + +### 1.2 Interactive Mode for Complex Commands + +**Problem**: `user create` requires 6 flags - easy to make mistakes. + +**Current**: +```bash +unityauth user create --email user@example.com --first-name John \ + --last-name Doe --password MyP@ss123 --tenant-id 1 --role-ids "2" +``` + +**Proposed**: When run without required arguments AND stdin is a TTY, launch interactive wizard: +```bash +$ unityauth user create +Email: user@example.com +First name: John +Last name: Doe +Password: ******** +Select tenant: + [1] Production Tenant + [2] Development Tenant +> 1 +Select roles (space to toggle, enter to confirm): + [ ] Unity Administrator + [x] Tenant Administrator + [x] Request Manager + +✓ User created successfully (ID: 42) +``` + +**Behavior**: +- Interactive mode triggers when: no required args provided AND `sys.stdin.isatty()` is True +- Non-interactive fallback: fail with usage message showing required flags +- Password input uses `click.prompt(hide_input=True)` +- Tenant/role selection fetches options from API dynamically + +**Commands to support**: +- `user create` - Full wizard +- `user update` - Role selection wizard +- `login` - Email/password prompts (already partially implemented) + +--- + +### 1.3 Add `whoami` Command + +**Problem**: `token-info` name is not intuitive for checking current identity. + +**Proposed**: Add `whoami` as an alias or standalone command: +```bash +$ unityauth whoami +Logged in as: admin@example.com +User ID: 5 +API: https://auth.example.com +Token expires: 2025-12-31 14:30:00 +``` + +**Implementation Options**: +1. Add `whoami` as alias to `token-info` +2. Add `whoami` as separate command with simpler output +3. Keep both: `whoami` for quick check, `token-info` for full details + +**Recommendation**: Option 3 - keep both commands. + +--- + +### 1.4 Allow Empty Role IDs to Remove All Roles + +**Problem**: The CLI prevents removing all roles from a user, but the backend supports it. + +**Backend behavior**: `PATCH /api/users/{id}/roles` accepts an empty `roles` array. The `UpdateUserRolesRequest` has no `@NotEmpty` validation on roles (unlike `AddUserRequest` which requires at least one role for user creation). + +**Current CLI validation** ([users.py:78-85](unityauth-cli/src/unityauth_cli/commands/users.py#L78-L85)): +```python +roles = [int(rid.strip()) for rid in role_ids.split(',')] +if not roles: + raise ValidationError("At least one role ID must be provided") +``` + +**Proposed**: Allow empty value to remove all roles: +```bash +# Remove all roles from user 5 in tenant 1 +unityauth user update 5 -t 1 -r "" +unityauth user update 5 -t 1 --role-ids "" + +# Or with explicit "none" keyword +unityauth user update 5 -t 1 -r none +``` + +**Use case**: Soft-disable a user by removing all their roles in a tenant without deleting the user account. This preserves audit history and allows re-enabling later. + +**Implementation**: +1. In `user update` command, check if `role_ids` is empty string or "none" +2. If so, send empty `roles: []` array to API +3. Add confirmation prompt: "This will remove all roles for user X in tenant Y. Continue? [y/N]" +4. Skip confirmation with `--yes` flag + +**Files to modify**: +- `commands/users.py` - Update validation logic in `update` command + +--- + +## Priority 2 (Medium Impact) + +### 2.1 Add `--dry-run` Flag for Mutating Commands + +**Problem**: Users cannot preview changes before execution. + +**Proposed**: +```bash +$ unityauth user create --dry-run -e user@example.com -f John -l Doe \ + -p MyP@ss123 -t 1 -r "2,3" + +[DRY RUN] Would create user: + Email: user@example.com + First Name: John + Last Name: Doe + Tenant ID: 1 + Role IDs: 2, 3 + +Run without --dry-run to execute. +``` + +**Commands to support**: +- `user create` +- `user update` +- `user update-profile` +- `batch create-users` + +**Implementation**: Add `--dry-run` / `-n` flag; skip API call and display planned action. + +--- + +### 2.2 Add Typo Suggestions (Did You Mean?) + +**Problem**: Typos result in unhelpful "No such command" errors. + +**Current**: +```bash +$ unityauth usr list +Error: No such command 'usr'. +``` + +**Proposed**: +```bash +$ unityauth usr list +Error: No such command 'usr'. + +Did you mean one of these? + user +``` + +**Implementation**: Use `click-didyoumean` package or implement Levenshtein distance matching. + +--- + +### 2.3 Add `unityauth init` Setup Wizard + +**Problem**: New users must manually figure out configuration. + +**Proposed**: +```bash +$ unityauth init +Welcome to UnityAuth CLI! + +API URL: https://auth.example.com +✓ Connection successful (API v1.0) +✓ Configuration saved to ~/.config/unityauth-cli/config.yml + +Would you like to log in now? [Y/n]: y +Email: admin@example.com +Password: ******** +✓ Logged in as admin@example.com + +Setup complete! Try these commands: + unityauth tenant list # List your tenants + unityauth user list -t 1 # List users in tenant 1 + unityauth --help # See all commands +``` + +**Behavior**: +1. Prompt for API URL +2. Test connection to API +3. Save configuration +4. Optionally trigger login flow +5. Display next steps + +--- + +### 2.4 Improve Empty State Messages + +**Problem**: Empty results don't guide users on next steps. + +**Current**: +``` +⚠ No users found +``` + +**Proposed**: +``` +No users found in tenant 1. + +To create a user: + unityauth user create -t 1 -e user@example.com ... + +To list users in a different tenant: + unityauth tenant list # See available tenants + unityauth user list -t # List users in that tenant +``` + +**Commands to update**: +- `user list` - Suggest user create +- `tenant list` - Explain permissions +- `role list` - Explain what roles are for +- `permissions list` - Explain permission model + +--- + +## Priority 3 (Nice to Have) + +### 3.1 Support Name-Based Lookups + +**Problem**: Users must look up IDs before running commands. + +**Current workflow**: +```bash +unityauth tenant list # Find tenant ID = 1 +unityauth role list # Find role ID = 2 +unityauth user create --tenant-id 1 --role-ids "2" ... +``` + +**Proposed**: Allow names as alternative to IDs: +```bash +unityauth user create --tenant "Production" --roles "Admin,Manager" ... +``` + +**Implementation**: +- Accept both ID (integer) and name (string) for tenant/role parameters +- Perform API lookup to resolve name to ID +- Cache results for session to avoid repeated lookups +- Error if name is ambiguous or not found + +--- + +### 3.2 Add Command Aliases + +**Problem**: Common operations require typing full command paths. + +**Proposed Aliases**: +| Full Command | Alias | +|--------------|-------| +| `unityauth user list` | `unityauth users` | +| `unityauth tenant list` | `unityauth tenants` | +| `unityauth role list` | `unityauth roles` | +| `unityauth permissions list` | `unityauth perms` | +| `unityauth token-info` | `unityauth whoami` | + +**Implementation**: Register additional commands that delegate to originals. + +--- + +### 3.3 Add `--quiet` / `-q` Flag for Scripting + +**Problem**: Success messages interfere with output parsing in scripts. + +**Current**: +```bash +$ unityauth user create ... +✓ User created successfully (ID: 42) +``` + +**Proposed with `--quiet`**: +```bash +$ unityauth user create --quiet ... +42 +``` + +**Behavior**: +- Suppress all non-error output +- Return only essential data (IDs, counts) +- Useful for scripting: `USER_ID=$(unityauth user create -q ...)` + +**Implementation**: Add global `--quiet` / `-q` flag; check before printing success/info messages. + +--- + +### 3.4 Group Commands in Help Output + +**Problem**: Help shows commands alphabetically, not by function. + +**Current**: +``` +Commands: + config Configuration management commands. + login Authenticate with UnityAuth API. + logout Clear stored authentication token. + permissions Permission discovery and verification... + role Role discovery commands. + tenant Tenant discovery and management commands. + token-info Display information about current... + user User account management commands. +``` + +**Proposed**: +``` +Authentication: + login Authenticate with UnityAuth API + logout Clear stored authentication token + whoami Display current user info + +User Management: + user User account management commands + +Discovery: + tenant Tenant discovery commands + role Role discovery commands + permissions Permission verification commands + +Configuration: + config Configuration management commands + +Run 'unityauth COMMAND --help' for more information. +``` + +**Implementation**: Use Click's command grouping or custom help formatter. + +--- + +### 3.5 Add Confirmation for Destructive Actions + +**Problem**: Role updates execute immediately without confirmation. + +**Proposed**: +```bash +$ unityauth user update 5 -t 1 -r "1" +This will replace all roles for user john@example.com in tenant Production. + +Current roles: Admin, Manager, Viewer +New roles: Admin + +Continue? [y/N]: y +✓ User 5 roles updated successfully +``` + +**Behavior**: +- Fetch current state before modification +- Show diff of changes +- Require explicit confirmation +- Add `--yes` / `-y` flag to skip confirmation for automation + +**Commands to add confirmation**: +- `user update` (role changes) +- `batch create-users` (bulk operations) + +--- + +### 3.6 Better Progress Feedback for Batch Operations + +**Problem**: Long batch operations appear to hang. + +**Proposed**: Use Rich progress bars: +```bash +$ unityauth batch create-users users.csv +Validating CSV... ✓ 100 records found + +Creating users ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45/100 • 00:23 remaining + +Summary: + ✓ 98 users created successfully + ✗ 2 users failed (see errors below) + +Errors: + Row 23: duplicate@example.com - User already exists + Row 67: invalid email format +``` + +**Implementation**: Use `rich.progress.Progress` for batch operations. + +--- + +## Priority 4 (Documentation) + +### 4.1 Document `--dry-run` Flag + +**Problem**: The `--dry-run` / `-n` flag was implemented but not documented in the user guide. + +**Affected Commands**: +- `user create --dry-run` +- `user update --dry-run` +- `user update-profile --dry-run` + +**Files to Update**: +- `docs/user-guide.md` - Add `--dry-run` option to each command's options table and examples + +**Documentation to Add** (example for `user create`): + +```markdown +**Optional Options:** + +| Option | Description | +|--------|-------------| +| `--dry-run`, `-n` | Preview changes without executing | + +**Examples:** + +\`\`\`bash +# Preview user creation without actually creating +unityauth user create --dry-run \ + --email user@example.com \ + --first-name John \ + --last-name Doe \ + --password "SecureP@ss123" \ + --tenant-id 1 \ + --role-ids "2,3" +\`\`\` +``` + +--- + +### 4.2 Document All Configuration Keys + +**Problem**: The `config set` documentation only lists 3 keys but the implementation supports 10+. + +**Current Documentation** (`docs/user-guide.md:273-280`): +- `api_url`, `default_format`, `timeout` + +**Missing Keys** (from `config.py:23-38`): +- `api_version` - API version compatibility +- `batch.max_size` - Maximum batch operation size +- `batch.continue_on_error` - Continue on errors in batch mode +- `batch.delay_ms` - Delay between batch API calls +- `output.show_headers` - Show table headers +- `output.table_style` - Table style (grid, simple, etc.) +- `output.color_enabled` - Enable colored output + +**Files to Update**: +- `docs/user-guide.md` - Expand config set Available Keys table + +**Documentation to Add**: + +```markdown +**Available Keys:** + +| Key | Description | Default | Example | +|-----|-------------|---------|---------| +| `api_url` | UnityAuth API endpoint | `null` | `https://auth.example.com` | +| `api_version` | API version for compatibility | `1.0` | `1.0` | +| `default_format` | Default output format | `table` | `table`, `json`, `csv` | +| `timeout` | Request timeout in seconds | `30` | `60` | +| `batch.max_size` | Max records per batch operation | `1000` | `500` | +| `batch.continue_on_error` | Continue batch on errors | `true` | `false` | +| `batch.delay_ms` | Delay between batch calls (ms) | `0` | `100` | +| `output.show_headers` | Show table headers | `true` | `false` | +| `output.table_style` | Table formatting style | `grid` | `simple`, `plain` | +| `output.color_enabled` | Enable colored output | `true` | `false` | + +**Examples:** + +\`\`\`bash +# Set nested configuration using dot notation +unityauth config set batch.max_size 500 +unityauth config set output.color_enabled false +\`\`\` +``` + +--- + +### 4.3 Add `init` to README Command Structure + +**Problem**: The README command structure tree omits the `init` command. + +**File to Update**: `README.md` + +**Current** (line 117-138): +``` +unityauth +├── login +├── logout +... +``` + +**Proposed**: +``` +unityauth +├── init # First-time setup wizard +├── login # Authenticate with UnityAuth +├── logout # Remove stored credentials +... +``` + +--- + +### 4.4 Add Installation Reference to User Guide + +**Problem**: User guide has no installation instructions; users must find README first. + +**File to Update**: `docs/user-guide.md` + +**Proposed**: Add after the title/intro: + +```markdown +## Installation + +See the [README](../README.md#installation) for installation instructions. + +**Quick Install:** +\`\`\`bash +cd unityauth-cli +python3 -m venv venv +source venv/bin/activate +python3 -m pip install -e . +\`\`\` +``` + +--- + +### 4.5 Add Quick Start to User Guide + +**Problem**: User guide jumps straight into detailed command reference without a quick orientation. + +**File to Update**: `docs/user-guide.md` + +**Proposed**: Add after Installation section: + +```markdown +## Quick Start + +\`\`\`bash +# 1. First-time setup +unityauth init + +# 2. Or configure manually and login +unityauth config set api_url https://auth.example.com +unityauth login + +# 3. Explore +unityauth tenant list +unityauth role list +unityauth user list --tenant-id 1 +\`\`\` + +For detailed command reference, see the sections below. +``` + +--- + +## Implementation Checklist + +### Phase 1: Quick Wins (P1) +- [x] Add short flags to common commands (`-t`, `-o`, `-v`, `-r`, `-s`) +- [ ] Add `whoami` command +- [x] Add interactive mode detection (`sys.stdin.isatty()`) - Implemented in login.py and init.py +- [ ] Implement interactive wizard for `user create` +- [ ] Allow empty `--role-ids` to remove all roles from user + +### Phase 2: Enhanced UX (P2) +- [x] Add `--dry-run` flag to mutating commands (user create, update, update-profile) +- [ ] Add typo suggestions with `click-didyoumean` +- [x] Create `unityauth init` setup wizard +- [ ] Improve empty state messages with next steps + +### Phase 3: Polish (P3) +- [ ] Support name-based tenant/role lookups +- [ ] Add command aliases +- [ ] Add `--quiet` flag for scripting +- [ ] Reorganize help output by category +- [ ] Add confirmation prompts for destructive actions +- [ ] Add Rich progress bars for batch operations (blocked: batch not implemented) + +### Phase 4: Documentation (P4) +- [x] Document `--dry-run` flag in user guide (user create/update/update-profile) +- [ ] Document all configuration keys in user guide (config set section) +- [ ] Add `init` command to README command structure tree +- [ ] Add installation reference to user guide +- [ ] Add quick start section to user guide + +**Status**: See [implementation-status.md](implementation-status.md) for full spec compliance matrix. + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `cli.py` | Add `-v` short flag, `--quiet` global flag, help formatting | +| `commands/login.py` | Add `-e` short flag, `whoami` command | +| `commands/users.py` | Add short flags, interactive mode, `--dry-run`, confirmation | +| `commands/tenants.py` | Add short flags, name-based lookup | +| `commands/roles.py` | Add short flags | +| `commands/permissions.py` | Add `-t`, `-s` short flags | +| `commands/config.py` | Add `init` command | +| `commands/batch.py` | Add `--dry-run`, Rich progress bars | +| `utils/interactive.py` | New file for interactive prompts | +| `pyproject.toml` | Add `click-didyoumean` dependency | +| `docs/user-guide.md` | Add `--dry-run` docs, config keys, installation, quick start | +| `README.md` | Add `init` to command structure tree | + +--- + +## Dependencies to Add + +```toml +[project.dependencies] +click-didyoumean = "^0.3.0" # Typo suggestions +``` + +Note: `rich` is already included for styled output. diff --git a/unityauth-cli/.gitignore b/unityauth-cli/.gitignore new file mode 100644 index 0000000..e38948f --- /dev/null +++ b/unityauth-cli/.gitignore @@ -0,0 +1,72 @@ +# Python +*.pyc +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log + +# Environment variables +.env +.env.* + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Black +.black/ diff --git a/unityauth-cli/README.md b/unityauth-cli/README.md new file mode 100644 index 0000000..9b6c0bf --- /dev/null +++ b/unityauth-cli/README.md @@ -0,0 +1,274 @@ +# UnityAuth CLI + +Command-line interface for UnityAuth administration. + +## Overview + +The UnityAuth CLI is a cross-platform command-line tool that enables system and tenant administrators to manage users, roles, and permissions in UnityAuth without using the web interface. It's designed for automation, batch operations, and scriptable workflows. + +## Current Status + +| Feature | Status | +|---------|--------| +| Authentication (login/logout/token-info) | Implemented | +| User Management (create/list/update/update-profile) | Implemented | +| Tenant Discovery (list/users) | Implemented | +| Role Discovery (list) | Implemented | +| Permissions Discovery (list) | Implemented | +| Configuration Management | Implemented | +| Service Discovery (list) | Not Available (see [Known Limitations](#known-limitations)) | +| Batch Operations | Planned | + +## Installation + +### Prerequisites + +- **Python 3.11 or higher** +- **pip** (Python package installer) +- **UnityAuth API endpoint URL** +- **Valid UnityAuth administrator credentials** + +#### Verify Python Installation + +```bash +python3 --version +# Should show Python 3.11.x or higher +``` + +#### Install Python (if needed) + +**macOS:** +```bash +brew install python@3.11 +``` + +**Ubuntu/Debian:** +```bash +sudo apt update +sudo apt install python3.11 python3.11-venv python3-pip +``` + +**Windows:** +Download from [python.org](https://www.python.org/downloads/) and run the installer. Check "Add Python to PATH" during installation. + +### Install from Source (Development) + +```bash +cd unityauth-cli + +# Create and activate virtual environment +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install in editable mode +python3 -m pip install -e . +``` + +### Verify Installation + +```bash +unityauth --version +``` + +## Quick Start + +### 1. Configure API Endpoint + +```bash +unityauth config set api_url https://auth.example.com +``` + +### 2. Login + +```bash +unityauth login +# Enter email and password when prompted +``` + +Your authentication token is securely stored in your OS credential manager (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux). + +### 3. Explore the System + +```bash +# List accessible tenants +unityauth tenant list + +# List available roles +unityauth role list + +# List users in a tenant +unityauth tenant users 1 +``` + +### 4. Create a User + +```bash +unityauth user create \ + --email newuser@example.com \ + --first-name John \ + --last-name Doe \ + --password "MySecureP@ss123" \ + --tenant-id 1 \ + --role-ids 2,3 +``` + +## Command Structure + +``` +unityauth +├── login # Authenticate with UnityAuth +├── logout # Remove stored credentials +├── token-info # Display session information +├── config # Configuration management +│ ├── show # Display current config +│ ├── set # Set a config value +│ └── edit # Open config in editor +├── user # User management +│ ├── create # Create a new user +│ ├── list # List users in a tenant +│ ├── update # Update user roles +│ └── update-profile # Update your own profile +├── tenant # Tenant discovery +│ ├── list # List accessible tenants +│ └── users # List users in a tenant +├── role # Role discovery +│ └── list # List available roles +└── permissions # Permission discovery + └── list # List your permissions for a tenant/service +``` + +## Global Options + +All commands support these global options: + +| Option | Description | +|--------|-------------| +| `--api-url TEXT` | Override API URL from config | +| `--format [table\|json\|csv]` | Output format (default: table) | +| `--verbose` | Enable debug output | +| `--version` | Show version and exit | +| `--help` | Show help message | + +## Output Formats + +```bash +# Human-readable table (default) +unityauth tenant list + +# Machine-readable JSON +unityauth tenant list --format json + +# Spreadsheet-compatible CSV +unityauth tenant list --format csv + +# Pipe JSON to jq for processing +unityauth tenant list --format json | jq '.[0].name' +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `UNITYAUTH_API_URL` | API endpoint URL | +| `UNITYAUTH_EMAIL` | Login email address | +| `UNITYAUTH_PASSWORD` | Login password (for non-interactive mode) | + +Example: +```bash +export UNITYAUTH_API_URL="https://auth.example.com" +unityauth login --email admin@example.com +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error (validation, network, API) | +| 2 | Authentication error | +| 3 | Permission/authorization error | +| 4 | Configuration error | + +## Configuration + +Configuration is stored in `~/.config/unityauth-cli/config.yml`: + +```yaml +api_url: https://auth.example.com +default_format: table +timeout: 30 +``` + +Manage configuration: +```bash +unityauth config show # View current settings +unityauth config set api_url URL # Set API endpoint +unityauth config set timeout 60 # Set request timeout +unityauth config edit # Open in editor +``` + +## Known Limitations + +### No Service Discovery API + +The UnityAuth backend does not expose an API endpoint to list available services. This means: + +- **No `service list` command**: The CLI cannot retrieve a list of services +- **Service IDs must be known in advance**: Commands like `permissions list` require a `--service-id` that users must know beforehand + +**Common Service IDs:** + +| ID | Name | Description | +|----|------|-------------| +| 1 | Libre311 | Libre311 citizen request management | + +**Workaround:** Contact your UnityAuth administrator for the correct service ID, or query the database directly if you have access: + +```sql +SELECT id, name, description FROM service WHERE status = 'ENABLED'; +``` + +**For Backend Developers:** To enable service discovery in the CLI, implement a `GET /api/services` endpoint in the UnityAuth backend. + +## Documentation + +- [User Guide](docs/user-guide.md) - Complete command reference + +- [API Reference](docs/api-reference.md) - UnityAuth API documentation + +## Development + +### Setup + +```bash +cd unityauth-cli +python3 -m venv venv +source venv/bin/activate +python3 -m pip install -e ".[dev]" +``` + +### Running Tests + +```bash +pytest tests/unit/ +pytest --cov=unityauth_cli +``` + +### Code Quality + +```bash +black src/ # Format code +flake8 src/ # Lint +mypy src/ # Type checking +``` + +## Security + +- **Token Storage**: JWT tokens encrypted using OS-native credential stores +- **HTTPS Required**: All API communication uses HTTPS +- **No Password Storage**: Only JWT tokens are persisted, never passwords +- **Secure Prompts**: Password input is hidden when typing + +## License + +MIT License - See LICENSE file for details. diff --git a/unityauth-cli/docs/user-guide.md b/unityauth-cli/docs/user-guide.md new file mode 100644 index 0000000..7b6b0ba --- /dev/null +++ b/unityauth-cli/docs/user-guide.md @@ -0,0 +1,907 @@ +# UnityAuth CLI User Guide + +Complete command reference for the UnityAuth command-line interface. + +## Table of Contents + +- [Global Options](#global-options) +- [Setup Commands](#setup-commands) + - [init](#init) +- [Authentication Commands](#authentication-commands) + - [login](#login) + - [logout](#logout) + - [token-info](#token-info) +- [Configuration Commands](#configuration-commands) + - [config show](#config-show) + - [config set](#config-set) + - [config edit](#config-edit) +- [User Management Commands](#user-management-commands) + - [user create](#user-create) + - [user list](#user-list) + - [user update](#user-update) + - [user update-profile](#user-update-profile) +- [Tenant Commands](#tenant-commands) + - [tenant list](#tenant-list) + - [tenant users](#tenant-users) +- [Role Commands](#role-commands) + - [role list](#role-list) +- [Permissions Commands](#permissions-commands) + - [permissions list](#permissions-list) +- [Known Limitations](#known-limitations) +- [Output Formats](#output-formats) +- [Exit Codes](#exit-codes) +- [Environment Variables](#environment-variables) +- [Troubleshooting](#troubleshooting) + +--- + +## Global Options + +These options are available for all commands: + +| Option | Short | Environment Variable | Description | +|--------|-------|---------------------|-------------| +| `--api-url TEXT` | | `UNITYAUTH_API_URL` | Override the API endpoint URL | +| `--format [table\|json\|csv]` | `-o` | - | Set output format (default: table) | +| `--verbose` | `-v` | - | Enable debug/verbose output | +| `--version` | | - | Show version and exit | +| `--help` | | - | Show help message and exit | + +**Examples:** + +```bash +# Override API URL for a single command +unityauth --api-url https://staging.example.com tenant list + +# Get JSON output (using short flag) +unityauth -o json role list + +# Enable verbose mode for debugging (using short flag) +unityauth -v login + +# Combine short flags +unityauth -v -o json tenant list +``` + +--- + +## Setup Commands + +### init + +Initialize UnityAuth CLI with a guided setup wizard. This is the recommended first command for new users. + +``` +unityauth init [OPTIONS] +``` + +**Options:** + +| Option | Description | +|--------|-------------| +| `--api-url TEXT` | UnityAuth API endpoint URL (skips prompt if provided) | +| `--skip-login` | Skip the login step after configuration | +| `--allow-http` | Allow insecure HTTP URLs (not recommended for production) | + +**Behavior:** + +1. Prompts for API URL (or accepts via `--api-url`) +2. Tests connection to the UnityAuth server +3. Saves configuration to `~/.config/unityauth-cli/config.yml` +4. Optionally prompts to log in (in interactive mode) +5. Displays helpful next steps + +**Examples:** + +```bash +# Interactive setup (recommended for first-time users) +unityauth init + +# Non-interactive setup with API URL +unityauth init --api-url https://auth.example.com --skip-login + +# Setup for local development (allows HTTP) +unityauth init --api-url http://localhost:8081 --allow-http --skip-login +``` + +**Sample Output:** + +``` +Welcome to UnityAuth CLI! +Let's get you set up. + +API URL: https://auth.example.com +Testing connection to https://auth.example.com... +✓ Connection successful +✓ Configuration saved to ~/.config/unityauth-cli/config.yml + +Would you like to log in now? [Y/n]: y +Email: admin@example.com +Password: ******** +✓ Logged in as admin@example.com + +Setup complete! + +Next steps: + $ unityauth tenant list # List your tenants + $ unityauth user list -t 1 # List users in tenant 1 + $ unityauth role list # List available roles + $ unityauth --help # See all commands +``` + +**Exit Codes:** + +| Code | Meaning | +|------|---------| +| 0 | Setup successful | +| 4 | Configuration error (invalid URL, connection failed) | + +--- + +## Authentication Commands + +### login + +Authenticate with UnityAuth and store credentials securely. + +``` +unityauth login [OPTIONS] +``` + +**Options:** + +| Option | Environment Variable | Description | +|--------|---------------------|-------------| +| `--email TEXT` | `UNITYAUTH_EMAIL` | Email address for login | +| `--password TEXT` | `UNITYAUTH_PASSWORD` | Password (prompts if not provided) | + +**Behavior:** + +- In interactive mode (TTY), prompts for email and password +- In non-interactive mode, requires `--email` and `--password` or environment variables +- Stores JWT token in OS-native credential manager: + - macOS: Keychain + - Windows: Credential Manager + - Linux: Secret Service (GNOME Keyring, KWallet) + +**Examples:** + +```bash +# Interactive login (recommended) +unityauth login + +# Login with email, prompt for password +unityauth login --email admin@example.com + +# Non-interactive login for scripts +unityauth login --email admin@example.com --password "MySecretP@ss" + +# Using environment variables +export UNITYAUTH_EMAIL="admin@example.com" +export UNITYAUTH_PASSWORD="MySecretP@ss" +unityauth login +``` + +**Exit Codes:** + +| Code | Meaning | +|------|---------| +| 0 | Login successful | +| 2 | Invalid credentials | +| 4 | API URL not configured | + +--- + +### logout + +Remove stored credentials and end the current session. + +``` +unityauth logout +``` + +**Behavior:** + +- Deletes the stored JWT token from the OS credential manager +- Does not invalidate the token on the server (tokens expire naturally) + +**Example:** + +```bash +unityauth logout +# Output: ✓ Logged out successfully +``` + +--- + +### token-info + +Display information about the current session and authentication token. + +``` +unityauth token-info +``` + +**Output Fields:** + +| Field | Description | +|-------|-------------| +| Email | Authenticated user's email | +| Name | User's full name (if available) | +| API Endpoint | Current API endpoint | +| Authenticated | Yes (shown when authenticated) | +| Token Expires | Token expiration time (Unix timestamp) | + +**Examples:** + +```bash +# Table output +unityauth token-info + +# JSON output for scripting +unityauth token-info -o json +``` + +**Sample Output:** + +``` ++---------------+-------------------------+ +| Field | Value | ++===============+=========================+ +| Email | admin@example.com | ++---------------+-------------------------+ +| Name | Admin User | ++---------------+-------------------------+ +| API Endpoint | https://auth.example.com| ++---------------+-------------------------+ +| Authenticated | Yes | ++---------------+-------------------------+ +| Token Expires | 1767196697 | ++---------------+-------------------------+ +``` + +--- + +## Configuration Commands + +### config show + +Display current configuration settings. + +``` +unityauth config show +``` + +**Output:** + +Shows all configured values from `~/.config/unityauth-cli/config.yml`: + +```yaml +api_url: https://auth.example.com +default_format: table +timeout: 30 +``` + +--- + +### config set + +Set a configuration value. + +``` +unityauth config set KEY VALUE +``` + +**Available Keys:** + +| Key | Description | Example | +|-----|-------------|---------| +| `api_url` | UnityAuth API endpoint | `https://auth.example.com` | +| `default_format` | Default output format | `table`, `json`, `csv` | +| `timeout` | Request timeout in seconds | `30`, `60` | + +**Examples:** + +```bash +# Set API endpoint +unityauth config set api_url https://auth.example.com + +# Change default output format +unityauth config set default_format json + +# Increase timeout for slow connections +unityauth config set timeout 60 +``` + +--- + +### config edit + +Open configuration file in the default text editor. + +``` +unityauth config edit +``` + +**Behavior:** + +- Opens `~/.config/unityauth-cli/config.yml` in `$EDITOR` or system default editor +- Creates the file if it doesn't exist + +--- + +## User Management Commands + +### user create + +Create a new user account with role assignments. + +``` +unityauth user create [OPTIONS] +``` + +**Required Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--email TEXT` | | User's email address (must be unique per tenant) | +| `--first-name TEXT` | | User's first name (1-100 characters) | +| `--last-name TEXT` | | User's last name (1-100 characters) | +| `--password TEXT` | | Initial password (minimum 8 characters) | +| `--tenant-id INTEGER` | `-t` | Tenant ID to assign the user to | +| `--role-ids TEXT` | `-r` | Comma-separated role IDs (e.g., "1,2,3") | + +**Optional Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--dry-run` | `-n` | Preview the user creation without actually creating | + +**Required Permissions:** + +- `AUTH_SERVICE_EDIT-SYSTEM` (Unity Administrator), or +- `AUTH_SERVICE_EDIT-TENANT` (Tenant Administrator for the target tenant) + +**Examples:** + +```bash +# Create a basic user with one role +unityauth user create \ + --email user@example.com \ + --first-name John \ + --last-name Doe \ + --password "SecureP@ss123" \ + -t 1 \ + -r 2 + +# Create a user with multiple roles (using short flags) +unityauth user create \ + --email admin@example.com \ + --first-name Jane \ + --last-name Admin \ + --password "AdminP@ss456" \ + -t 1 -r "1,2,3" + +# Preview user creation without executing (dry run) +unityauth user create --dry-run \ + --email user@example.com \ + --first-name John \ + --last-name Doe \ + --password "SecureP@ss123" \ + -t 1 -r 2 +``` + +**Common Errors:** + +| Error | Cause | Solution | +|-------|-------|----------| +| "User already exists" | Email already used in this tenant | Use different email or update existing user | +| "Permission denied" | Insufficient permissions | Use account with admin permissions | +| "Tenant not found" | Invalid tenant ID | Check tenant ID with `tenant list` | + +--- + +### user list + +List all users in a specific tenant. + +``` +unityauth user list -t TENANT_ID +``` + +**Required Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--tenant-id INTEGER` | `-t` | Tenant ID to list users from | + +**Output Columns:** + +| Column | Description | +|--------|-------------| +| ID | User's unique identifier | +| Email | User's email address | +| First Name | User's first name | +| Last Name | User's last name | +| Roles | Assigned role IDs | + +**Examples:** + +```bash +# List users in tenant 1 +unityauth user list -t 1 + +# Get JSON output for scripting (using short flags) +unityauth user list -t 1 -o json + +# Export to CSV +unityauth user list -t 1 -o csv > users.csv +``` + +--- + +### user update + +Update role assignments for an existing user. + +``` +unityauth user update USER_ID -t TENANT_ID -r ROLE_IDS +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `USER_ID` | The user's ID (from `user list`) | + +**Required Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--tenant-id INTEGER` | `-t` | Tenant ID where roles are assigned | +| `--role-ids TEXT` | `-r` | Comma-separated role IDs to assign | + +**Optional Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--dry-run` | `-n` | Preview the role changes without actually updating | + +**Behavior:** + +- Replaces ALL current roles for the user in the specified tenant +- Does not affect roles in other tenants + +**Examples:** + +```bash +# Update user 5's roles in tenant 1 +unityauth user update 5 -t 1 -r "2,3" + +# Assign a single role +unityauth user update 10 -t 1 -r 2 + +# Preview role changes without executing (dry run) +unityauth user update 5 --dry-run -t 1 -r "2,3" +``` + +--- + +### user update-profile + +Update your own user profile (first name, last name, or password). + +``` +unityauth user update-profile USER_ID [OPTIONS] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `USER_ID` | Your user ID (from `token-info`) | + +**Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--first-name TEXT` | | New first name (1-100 characters) | +| `--last-name TEXT` | | New last name (1-100 characters) | +| `--password TEXT` | | New password (minimum 8 characters) | +| `--dry-run` | `-n` | Preview the profile changes without actually updating | + +**Behavior:** + +- This is a **self-service** command: you can only update your own profile +- The `USER_ID` must match your authenticated user ID exactly +- At least one option must be provided +- Only the specified fields are updated; others remain unchanged + +**Important Limitation:** + +This command cannot be used by administrators to update other users' profiles. The backend enforces that the authenticated user can only modify their own account. To update another user's name or password, use the web interface or API directly. + +**Examples:** + +```bash +# First, check your user ID +unityauth token-info + +# Update your first name +unityauth user update-profile 5 --first-name John + +# Update your last name +unityauth user update-profile 5 --last-name Smith + +# Change your password +unityauth user update-profile 5 --password "NewSecureP@ss123" + +# Update multiple fields at once +unityauth user update-profile 5 --first-name John --last-name Smith --password "NewP@ss" + +# Preview profile changes without executing (dry run) +unityauth user update-profile 5 --dry-run --first-name John --last-name Smith +``` + +**Common Errors:** + +| Error | Cause | Solution | +|-------|-------|----------| +| "User ID mismatch" | The user ID doesn't match your authenticated user | Use `token-info` to find your correct user ID. You can only update your own profile. | +| "At least one field must be provided" | No options specified | Provide `--first-name`, `--last-name`, or `--password` | +| "Password must be at least 8 characters" | Password too short | Use a longer password | + +**Note:** To update another user's profile or roles, use `user update` (requires admin permissions). + +--- + +## Tenant Commands + +### tenant list + +List all tenants accessible to the current user. + +``` +unityauth tenant list +``` + +**Behavior:** + +- Unity Administrators see all tenants +- Tenant Administrators see only their assigned tenants + +**Output Columns:** + +| Column | Description | +|--------|-------------| +| ID | Tenant's unique identifier | +| Name | Tenant's display name | + +**Examples:** + +```bash +# List all accessible tenants +unityauth tenant list + +# Get JSON output (using short flag) +unityauth tenant list -o json + +# Get just tenant names using jq +unityauth tenant list -o json | jq -r '.[].name' +``` + +--- + +### tenant users + +List all users belonging to a specific tenant. + +``` +unityauth tenant users TENANT_ID +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `TENANT_ID` | The tenant's ID | + +**Output Columns:** + +| Column | Description | +|--------|-------------| +| ID | User's unique identifier | +| Email | User's email address | +| First Name | User's first name | +| Last Name | User's last name | +| Roles | Assigned role IDs | + +**Examples:** + +```bash +# List users in tenant 1 +unityauth tenant users 1 + +# Get JSON output (using short flag) +unityauth tenant users 1 -o json + +# Count users in a tenant +unityauth tenant users 1 -o json | jq length +``` + +--- + +## Role Commands + +### role list + +List all roles defined in the system. + +``` +unityauth role list +``` + +**Output Columns:** + +| Column | Description | +|--------|-------------| +| ID | Role's unique identifier | +| Name | Role's display name | +| Description | Role's description | + +**Examples:** + +```bash +# List all roles +unityauth role list + +# Get JSON output (using short flag) +unityauth role list -o json + +# Find a specific role ID +unityauth role list -o json | jq '.[] | select(.name == "Tenant Administrator")' +``` + +--- + +## Permissions Commands + +### permissions list + +List your permissions for a specific tenant and service. + +``` +unityauth permissions list -t TENANT_ID -s SERVICE_ID +``` + +**Required Options:** + +| Option | Short | Description | +|--------|-------|-------------| +| `--tenant-id INTEGER` | `-t` | Tenant ID to check permissions for | +| `--service-id INTEGER` | `-s` | Service ID to check permissions for | + +**Output:** + +Returns all permissions the authenticated user has for the specified tenant and service combination. + +**Examples:** + +```bash +# List your permissions for tenant 1 and Libre311 service (ID: 1) +unityauth permissions list -t 1 -s 1 + +# Get JSON output (using short flags) +unityauth permissions list -t 1 -s 1 -o json + +# Get CSV output +unityauth permissions list -t 1 -s 1 -o csv +``` + +**Sample Output:** + +``` +┌───────────────────────────────┐ +│ Permission │ +├───────────────────────────────┤ +│ AUTH_SERVICE_VIEW-SYSTEM │ +│ AUTH_SERVICE_EDIT-SYSTEM │ +│ LIBRE311_ADMIN_VIEW-TENANT │ +│ LIBRE311_ADMIN_EDIT-TENANT │ +└───────────────────────────────┘ +``` + +**Common Errors:** + +| Error | Cause | Solution | +|-------|-------|----------| +| "No tenant found" | Invalid tenant ID | Check tenant ID with `tenant list` | +| "The service does not exist" | Invalid service ID | See [Known Limitations](#known-limitations) | +| "Tenant/Service not available" | User not authorized for this combination | Contact your administrator | + +--- + +## Known Limitations + +### Service Discovery + +**There is currently no API endpoint to list available services.** The UnityAuth backend does not expose a `/api/services` endpoint, so the CLI cannot provide a `service list` command. + +**What this means:** + +- Commands that require `--service-id` (like `permissions list`) need you to know the service ID in advance +- Service IDs are configured by database administrators and are deployment-specific + +**Current Services:** + +In most UnityAuth deployments, the following service is available: + +| ID | Name | Description | +|----|------|-------------| +| 1 | Libre311 | Libre311 citizen request management system | + +**Workaround:** + +If you don't know the service ID, contact your UnityAuth administrator or check the database directly: + +```sql +SELECT id, name, description, status FROM service; +``` + +**For Developers:** + +To add a `service list` command, a new API endpoint would need to be implemented in the UnityAuth backend (e.g., `GET /api/services`). + +--- + +## Output Formats + +### Table (Default) + +Human-readable ASCII table format: + +``` +┌────┬─────────────────────┬────────────┬───────────┬───────┐ +│ ID │ Email │ First Name │ Last Name │ Roles │ +├────┼─────────────────────┼────────────┼───────────┼───────┤ +│ 1 │ admin@example.com │ Admin │ User │ 1, 2 │ +│ 2 │ user@example.com │ Regular │ User │ 3 │ +└────┴─────────────────────┴────────────┴───────────┴───────┘ +``` + +### JSON + +Machine-readable JSON for scripting: + +```json +[ + { + "id": 1, + "email": "admin@example.com", + "firstName": "Admin", + "lastName": "User", + "roles": [1, 2] + } +] +``` + +### CSV + +Spreadsheet-compatible comma-separated values: + +```csv +id,email,firstName,lastName,roles +1,admin@example.com,Admin,User,"1, 2" +2,user@example.com,Regular,User,3 +``` + +--- + +## Exit Codes + +| Code | Name | Description | +|------|------|-------------| +| 0 | Success | Command completed successfully | +| 1 | General Error | Validation, network, or API error | +| 2 | Authentication Error | Invalid credentials or expired token | +| 3 | Authorization Error | Insufficient permissions | +| 4 | Configuration Error | Missing or invalid configuration | + +**Scripting Example:** + +```bash +#!/bin/bash +unityauth login --email "$EMAIL" --password "$PASSWORD" +case $? in + 0) echo "Login successful" ;; + 2) echo "Invalid credentials" >&2; exit 1 ;; + 4) echo "API URL not configured" >&2; exit 1 ;; + *) echo "Unknown error" >&2; exit 1 ;; +esac +``` + +--- + +## Environment Variables + +| Variable | Description | Example | +|----------|-------------|---------| +| `UNITYAUTH_API_URL` | API endpoint URL | `https://auth.example.com` | +| `UNITYAUTH_EMAIL` | Login email | `admin@example.com` | +| `UNITYAUTH_PASSWORD` | Login password | `secret` | + +**Usage in Scripts:** + +```bash +#!/bin/bash +export UNITYAUTH_API_URL="https://auth.example.com" +export UNITYAUTH_EMAIL="admin@example.com" +export UNITYAUTH_PASSWORD="$ADMIN_PASSWORD" + +# Login +unityauth login + +# Perform operations +unityauth user list -t 1 -o json > users.json + +# Cleanup +unityauth logout +``` + +--- + +## Troubleshooting + +### "API URL not configured" + +```bash +# First-time setup (recommended) +unityauth init + +# Or manually set API URL +unityauth config set api_url https://auth.example.com + +# Check current configuration +unityauth config show +``` + +### "Not authenticated" + +```bash +# Check current session +unityauth token-info + +# Re-authenticate +unityauth login +``` + +### "Permission denied" + +Your account lacks the required permissions. Contact your Unity Administrator to grant: + +- `AUTH_SERVICE_VIEW-SYSTEM` or `AUTH_SERVICE_VIEW-TENANT` for read operations +- `AUTH_SERVICE_EDIT-SYSTEM` or `AUTH_SERVICE_EDIT-TENANT` for write operations + +### Network/Connection Errors + +```bash +# Test connectivity +curl -I https://auth.example.com/keys + +# Increase timeout +unityauth config set timeout 60 + +# Use verbose mode for debugging +unityauth --verbose tenant list +``` + +### Token Expired + +```bash +# Check token status +unityauth token-info + +# Re-authenticate +unityauth logout +unityauth login +``` diff --git a/unityauth-cli/pyproject.toml b/unityauth-cli/pyproject.toml new file mode 100644 index 0000000..5e15733 --- /dev/null +++ b/unityauth-cli/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "unityauth-cli" +version = "1.0.0" +description = "Command-line interface for UnityAuth administration" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "UnityAuth Team"} +] +keywords = ["auth", "authentication", "cli", "administration"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "click>=8.1.0,<9.0.0", + "requests>=2.31.0,<3.0.0", + "keyring>=24.3.0,<25.0.0", + "PyYAML>=6.0,<7.0", + "tabulate>=0.9.0,<1.0.0", + "rich>=13.7.0,<14.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-mock>=3.12.0", + "testcontainers>=3.7.0", + "black>=23.0.0", + "flake8>=6.0.0", + "mypy>=1.5.0", +] + +[project.scripts] +unityauth = "unityauth_cli.__main__:main" + +[project.urls] +Homepage = "https://github.com/UnityFoundation-io/UnityAuth" +Repository = "https://github.com/UnityFoundation-io/UnityAuth" +Issues = "https://github.com/UnityFoundation-io/UnityAuth/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.black] +line-length = 100 +target-version = ['py311'] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true diff --git a/unityauth-cli/requirements-dev.txt b/unityauth-cli/requirements-dev.txt new file mode 100644 index 0000000..65c1977 --- /dev/null +++ b/unityauth-cli/requirements-dev.txt @@ -0,0 +1,20 @@ +# UnityAuth CLI Development Dependencies +# Install with: pip install -r requirements-dev.txt + +# Include production dependencies +-r requirements.txt + +# Testing +pytest==7.4.4 +pytest-mock==3.12.0 +testcontainers==3.7.1 + +# Code Quality +black==23.12.1 +flake8==6.1.0 +mypy==1.8.0 + +# Type stubs +types-requests==2.31.0.20240125 +types-PyYAML==6.0.12.12 +types-tabulate==0.9.0.20240106 diff --git a/unityauth-cli/requirements.txt b/unityauth-cli/requirements.txt new file mode 100644 index 0000000..027c38a --- /dev/null +++ b/unityauth-cli/requirements.txt @@ -0,0 +1,10 @@ +# UnityAuth CLI Production Dependencies +# Generated from pyproject.toml +# Install with: pip install -r requirements.txt + +click==8.1.7 +requests==2.31.0 +keyring==24.3.1 +PyYAML==6.0.1 +tabulate==0.9.0 +rich==13.7.1 diff --git a/unityauth-cli/src/unityauth_cli/__init__.py b/unityauth-cli/src/unityauth_cli/__init__.py new file mode 100644 index 0000000..6ef81b6 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/__init__.py @@ -0,0 +1,13 @@ +"""UnityAuth CLI - Command-line interface for UnityAuth administration. + +This package provides a command-line tool for managing UnityAuth users, roles, +and permissions. It enables administrators to perform user provisioning, +permission verification, and batch operations through a simple CLI interface. +""" + +__version__ = "1.0.0" +__author__ = "UnityAuth Team" +__license__ = "MIT" + +# API version this CLI is compatible with +SUPPORTED_API_VERSION = "1.0" diff --git a/unityauth-cli/src/unityauth_cli/__main__.py b/unityauth-cli/src/unityauth_cli/__main__.py new file mode 100644 index 0000000..901d5d9 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/__main__.py @@ -0,0 +1,24 @@ +"""Entry point for UnityAuth CLI. + +This module is executed when running: python -m unityauth_cli +""" + +import sys + +from unityauth_cli.cli import cli, handle_error, register_commands + + +def main() -> None: + """Main entry point for CLI execution.""" + # Register all commands + register_commands() + + # Run CLI + try: + cli(prog_name='unityauth') + except Exception as e: + handle_error(e) + + +if __name__ == '__main__': + main() diff --git a/unityauth-cli/src/unityauth_cli/auth.py b/unityauth-cli/src/unityauth_cli/auth.py new file mode 100644 index 0000000..7113d95 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/auth.py @@ -0,0 +1,87 @@ +"""Authentication module for UnityAuth CLI. + +Handles token storage and retrieval using OS-native secure storage (keyring). +""" + +from typing import Optional + +import keyring + +from unityauth_cli.utils.errors import AuthenticationError + +# Service name for keyring storage +SERVICE_NAME = "unityauth-cli" + + +def store_token(api_url: str, token: str) -> None: + """Store JWT token in OS-native secure storage. + + Args: + api_url: API endpoint URL (used as username for keyring) + token: JWT bearer token to store + + Raises: + AuthenticationError: If token storage fails + """ + try: + keyring.set_password(SERVICE_NAME, api_url, token) + except Exception as e: + raise AuthenticationError( + f"Failed to store authentication token: {e}", + "Check that your OS keyring service is available" + ) from e + + +def get_token(api_url: str) -> Optional[str]: + """Retrieve JWT token from OS-native secure storage. + + Args: + api_url: API endpoint URL (used as username for keyring) + + Returns: + JWT bearer token if found, None otherwise + + Raises: + AuthenticationError: If token retrieval fails + """ + try: + return keyring.get_password(SERVICE_NAME, api_url) + except Exception as e: + raise AuthenticationError( + f"Failed to retrieve authentication token: {e}", + "Check that your OS keyring service is available" + ) from e + + +def delete_token(api_url: str) -> None: + """Delete JWT token from OS-native secure storage. + + Args: + api_url: API endpoint URL (used as username for keyring) + + Raises: + AuthenticationError: If token deletion fails + """ + try: + keyring.delete_password(SERVICE_NAME, api_url) + except keyring.errors.PasswordDeleteError: + # Token doesn't exist - not an error + pass + except Exception as e: + raise AuthenticationError( + f"Failed to delete authentication token: {e}", + "Check that your OS keyring service is available" + ) from e + + +def has_token(api_url: str) -> bool: + """Check if a token exists for the given API URL. + + Args: + api_url: API endpoint URL + + Returns: + True if token exists, False otherwise + """ + token = get_token(api_url) + return token is not None diff --git a/unityauth-cli/src/unityauth_cli/cli.py b/unityauth-cli/src/unityauth_cli/cli.py new file mode 100644 index 0000000..f0b2081 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/cli.py @@ -0,0 +1,339 @@ +"""Main CLI command group for UnityAuth CLI. + +Defines the root command and global options. +""" + +import functools +import sys +from typing import Callable, Optional, TypeVar + +import click +from rich.console import Console + +from unityauth_cli import __version__ +from unityauth_cli.config import Configuration +from unityauth_cli.utils.errors import AuthenticationError, ConfigurationError, UnityAuthCLIError + +# Global console for Rich output +console = Console() + + +class CLIContext: + """Context object passed to all CLI commands.""" + + def __init__(self) -> None: + """Initialize CLI context.""" + self.config: Optional[Configuration] = None + self.api_url: Optional[str] = None + self.output_format: str = "table" + self.verbose: bool = False + + +pass_context = click.make_pass_decorator(CLIContext, ensure=True) + +# Type variable for decorated functions +F = TypeVar('F', bound=Callable[..., None]) + + +def require_auth(f: F) -> F: + """Decorator that ensures API URL is configured and user is authenticated. + + Checks that: + 1. API URL is configured (from config or --api-url flag) + 2. User has a valid token stored in the keyring + 3. Creates an API client and passes it as the 'client' keyword argument + + Must be used after @pass_context decorator. + + Example: + @click.command() + @pass_context + @require_auth + def my_command(ctx: CLIContext, client: UnityAuthAPIClient) -> None: + # client is ready to use + response = client.get('/api/users') + """ + @functools.wraps(f) + def wrapper(ctx: CLIContext, *args, **kwargs): + # Import here to avoid circular imports + from unityauth_cli import auth + from unityauth_cli.client import UnityAuthAPIClient + + # Check API URL is configured + if not ctx.api_url: + raise ConfigurationError( + "API URL not configured", + "Set API URL: unityauth config set api_url https://auth.example.com" + ) + + # Get token from keyring + token = auth.get_token(ctx.api_url) + if not token: + raise AuthenticationError( + "Not authenticated", + "Run: unityauth login" + ) + + # Get timeout from config if available + timeout = 30 + if ctx.config: + timeout = ctx.config.get('timeout', 30) + + # Create API client and pass it to the command + client = UnityAuthAPIClient(ctx.api_url, token=token, timeout=timeout) + kwargs['client'] = client + + return f(ctx, *args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def require_config(f: F) -> F: + """Decorator that ensures API URL is configured. + + Lighter weight than @require_auth - use when authentication is not required + (e.g., login command itself). + + Must be used after @pass_context decorator. + + Example: + @click.command() + @pass_context + @require_config + def login(ctx: CLIContext) -> None: + # ctx.api_url is guaranteed to be set + ... + """ + @functools.wraps(f) + def wrapper(ctx: CLIContext, *args, **kwargs): + if not ctx.api_url: + raise ConfigurationError( + "API URL not configured", + "Set API URL: unityauth config set api_url https://auth.example.com" + ) + return f(ctx, *args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def format_option(f: F) -> F: + """Decorator that adds -o/--format option to a command. + + Allows format to be specified after the command (more intuitive): + unityauth tenant list -o json + + If provided, overrides the global format from the parent context. + Must be used before @pass_context decorator. + + Example: + @click.command() + @format_option + @pass_context + @require_auth + def list_tenants(ctx: CLIContext, client: UnityAuthAPIClient) -> None: + # ctx.output_format is set (from local -o or global -o or config) + ... + """ + @functools.wraps(f) + def wrapper(*args, output_format: Optional[str] = None, **kwargs): + # Override context format if local option provided + if output_format: + # Get CLIContext from Click's current context + click_ctx = click.get_current_context(silent=True) + if click_ctx: + cli_ctx = click_ctx.find_object(CLIContext) + if cli_ctx: + cli_ctx.output_format = output_format.lower() + + return f(*args, **kwargs) + + # Apply the click option decorator + decorated = click.option( + '-o', '--format', + 'output_format', + type=click.Choice(['table', 'json', 'csv'], case_sensitive=False), + help='Output format (default: table)', + )(wrapper) + + return decorated # type: ignore[return-value] + + +@click.group() +@click.option( + '--api-url', + envvar='UNITYAUTH_API_URL', + help='UnityAuth API endpoint URL (overrides config file)', +) +@click.option( + '-o', '--format', + 'output_format', + type=click.Choice(['table', 'json', 'csv'], case_sensitive=False), + help='Output format (default: table)', +) +@click.option( + '-v', '--verbose', + is_flag=True, + help='Enable verbose/debug output', +) +@click.version_option(version=__version__, prog_name='unityauth-cli') +@click.pass_context +def cli(ctx: click.Context, api_url: Optional[str], output_format: Optional[str], verbose: bool) -> None: + """UnityAuth CLI - Command-line interface for UnityAuth administration. + + Manage users, roles, and permissions in UnityAuth from the command line. + + \b + Common commands: + unityauth login # Authenticate and store token + unityauth user create ... # Create a new user + unityauth user list --tenant-id 1 # List users in tenant + unityauth tenant list # List accessible tenants + unityauth role list # List available roles + + \b + Option placement: + Global options (--verbose, --format) go BEFORE the command: + unityauth --verbose user list + unityauth -o json tenant list + Command options go AFTER the command: + unityauth user create --email user@example.com + + For command-specific help: + unityauth COMMAND --help + """ + # Initialize CLI context + cli_ctx = ctx.ensure_object(CLIContext) + cli_ctx.verbose = verbose + + # Load configuration + try: + cli_ctx.config = Configuration() + + # Override API URL if provided + if api_url: + cli_ctx.api_url = api_url + else: + cli_ctx.api_url = cli_ctx.config.get('api_url') + + # Set output format + if output_format: + cli_ctx.output_format = output_format.lower() + else: + cli_ctx.output_format = cli_ctx.config.get('default_format', 'table') + + except Exception as e: + error(f"Failed to initialize CLI: {e}") + sys.exit(4) + + +def success(message: str) -> None: + """Display success message in green. + + Args: + message: Success message to display + """ + console.print(f"✓ {message}", style="bold green") + + +def error(message: str, details: str = "") -> None: + """Display error message in red. + + Args: + message: Error message to display + details: Optional additional details or guidance + """ + console.print(f"✗ {message}", style="bold red") + if details: + console.print(f"→ {details}", style="yellow") + + +def warning(message: str) -> None: + """Display warning message in yellow. + + Args: + message: Warning message to display + """ + console.print(f"⚠ {message}", style="bold yellow") + + +def info(message: str) -> None: + """Display info message. + + Args: + message: Info message to display + """ + console.print(message) + + +def handle_error(e: Exception) -> None: + """Handle CLI errors and exit with appropriate code. + + Args: + e: Exception to handle + """ + if isinstance(e, UnityAuthCLIError): + error(e.message, e.details) + sys.exit(e.exit_code) + else: + error(f"Unexpected error: {e}") + sys.exit(1) + + +# Import and register commands +# Avoid circular imports by importing here rather than at module level +def register_commands() -> None: + """Register all CLI commands with the main group.""" + from unityauth_cli.commands.login import login, logout, token_info + from unityauth_cli.commands.config import config + from unityauth_cli.commands.init import init + from unityauth_cli.commands.users import create, update, update_profile, list_users + from unityauth_cli.commands.tenants import list_tenants, tenant_users + from unityauth_cli.commands.roles import list_roles + from unityauth_cli.commands.permissions import list_permissions + + # Register setup command + cli.add_command(init) + + # Register authentication commands + cli.add_command(login) + cli.add_command(logout) + cli.add_command(token_info) + + # Register configuration commands + cli.add_command(config) + + # Register user management commands + @cli.group() + def user(): + """User account management commands.""" + pass + + user.add_command(create) + user.add_command(update) + user.add_command(update_profile) + user.add_command(list_users, name='list') + + # Register tenant discovery commands + @cli.group() + def tenant(): + """Tenant discovery and management commands.""" + pass + + tenant.add_command(list_tenants, name='list') + tenant.add_command(tenant_users, name='users') + + # Register role discovery commands + @cli.group() + def role(): + """Role discovery commands.""" + pass + + role.add_command(list_roles, name='list') + + # Register permissions commands + @cli.group() + def permissions(): + """Permission discovery and verification commands.""" + pass + + permissions.add_command(list_permissions, name='list') diff --git a/unityauth-cli/src/unityauth_cli/client.py b/unityauth-cli/src/unityauth_cli/client.py new file mode 100644 index 0000000..30ac621 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/client.py @@ -0,0 +1,281 @@ +"""UnityAuth API client for making HTTP requests. + +Handles authentication, request/response processing, and error mapping. +""" + +from typing import Any, Dict, Optional + +import requests + +from unityauth_cli import SUPPORTED_API_VERSION, __version__ +from unityauth_cli.utils.errors import ( + AuthenticationError, + AuthorizationError, + NetworkError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, + VersionMismatchError, +) + + +class UnityAuthAPIClient: + """HTTP client for UnityAuth API. + + Manages sessions, authentication headers, and error handling. + """ + + def __init__(self, base_url: str, token: Optional[str] = None, timeout: int = 30) -> None: + """Initialize API client. + + Args: + base_url: Base URL of UnityAuth API (e.g., https://auth.example.com) + token: Optional JWT bearer token for authentication + timeout: Request timeout in seconds (default: 30) + """ + self.base_url = base_url.rstrip('/') + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({ + 'User-Agent': f'unityauth-cli/{__version__}', + 'Content-Type': 'application/json', + }) + + if token: + self.set_token(token) + + self._version_checked = False + + def set_token(self, token: str) -> None: + """Set authentication token for subsequent requests. + + Args: + token: JWT bearer token + """ + self.session.headers['Authorization'] = f'Bearer {token}' + + def clear_token(self) -> None: + """Remove authentication token from session.""" + self.session.headers.pop('Authorization', None) + + def get(self, endpoint: str, **kwargs: Any) -> Any: + """Make GET request to API. + + Args: + endpoint: API endpoint path (e.g., /api/users) + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response or None + + Raises: + Various UnityAuthCLIError subclasses based on response + """ + return self._request('GET', endpoint, **kwargs) + + def post(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Any: + """Make POST request to API. + + Args: + endpoint: API endpoint path + data: Request body as dictionary + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response or None + + Raises: + Various UnityAuthCLIError subclasses based on response + """ + return self._request('POST', endpoint, json=data, **kwargs) + + def put(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Any: + """Make PUT request to API. + + Args: + endpoint: API endpoint path + data: Request body as dictionary + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response or None + + Raises: + Various UnityAuthCLIError subclasses based on response + """ + return self._request('PUT', endpoint, json=data, **kwargs) + + def patch(self, endpoint: str, data: Optional[Dict[str, Any]] = None, **kwargs: Any) -> Any: + """Make PATCH request to API. + + Args: + endpoint: API endpoint path + data: Request body as dictionary + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response or None + + Raises: + Various UnityAuthCLIError subclasses based on response + """ + return self._request('PATCH', endpoint, json=data, **kwargs) + + def delete(self, endpoint: str, **kwargs: Any) -> Any: + """Make DELETE request to API. + + Args: + endpoint: API endpoint path + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response or None + + Raises: + Various UnityAuthCLIError subclasses based on response + """ + return self._request('DELETE', endpoint, **kwargs) + + def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any: + """Internal method to make HTTP requests with error handling. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + endpoint: API endpoint path + **kwargs: Additional arguments for requests + + Returns: + Parsed JSON response or None + + Raises: + NetworkError: On connection failures + AuthenticationError: On 401 responses + PermissionError: On 403 responses + NotFoundError: On 404 responses + ValidationError: On 400/422 responses + RateLimitError: On 429 responses + ServerError: On 5xx responses + """ + url = f'{self.base_url}{endpoint}' + kwargs.setdefault('timeout', self.timeout) + + try: + response = self.session.request(method, url, **kwargs) + + # Debug logging for troubleshooting + # import sys + # print(f"DEBUG: {method} {url} -> {response.status_code}", file=sys.stderr) + # print(f"DEBUG: Response: {response.text[:200] if response.text else 'empty'}", file=sys.stderr) + + # Check version compatibility on first authenticated request + if not self._version_checked and 'Authorization' in self.session.headers: + self._check_version_compatibility(response) + self._version_checked = True + + # Handle HTTP errors + if not response.ok: + self._handle_http_error(response) + + # Return JSON if present, otherwise None + if response.content: + return response.json() + return None + + except requests.ConnectionError as e: + raise NetworkError( + f"Could not connect to {self.base_url}", + f"Check API endpoint configuration: unityauth config show\n" + f"Verify network connectivity: curl {self.base_url}/keys" + ) from e + except requests.Timeout as e: + raise NetworkError( + f"Request timed out after {self.timeout} seconds", + "Increase timeout: unityauth config set timeout 60" + ) from e + except requests.RequestException as e: + raise NetworkError(f"Network error: {e}") from e + + def _handle_http_error(self, response: requests.Response) -> None: + """Map HTTP errors to custom exceptions. + + Args: + response: HTTP response with error status + + Raises: + Appropriate UnityAuthCLIError subclass + """ + status_code = response.status_code + + # Try to extract error message from response + message = None + try: + error_data = response.json() + # Try multiple possible field names that different frameworks use + message = ( + error_data.get('message') or + error_data.get('error') or + error_data.get('_embedded', {}).get('message') or + error_data.get('title') + ) + except Exception: + pass + + # If no message found in JSON, use response text + if not message: + message = response.text if response.text else f"HTTP {status_code}" + + # Map status codes to exceptions + if status_code == 401: + raise AuthenticationError( + f"Authentication failed: {message}", + "Run: unityauth login" + ) + elif status_code == 403: + raise AuthorizationError( + f"Permission denied: {message}", + "Contact your administrator to grant required permissions" + ) + elif status_code == 404: + raise NotFoundError(f"Resource not found: {message}") + elif status_code == 400: + raise ValidationError(f"Invalid request: {message}") + elif status_code == 422: + raise ValidationError(f"Validation failed: {message}") + elif status_code == 429: + # Extract retry-after header if present + retry_after = int(response.headers.get('Retry-After', 60)) + raise RateLimitError( + f"Rate limit exceeded: {message}", + retry_after=retry_after + ) + elif status_code >= 500: + raise ServerError(message, status_code=status_code) + else: + raise NetworkError(f"HTTP {status_code}: {message}") + + def _check_version_compatibility(self, response: requests.Response) -> None: + """Check if CLI version is compatible with API version. + + Args: + response: Response from API (may contain version header) + + Raises: + VersionMismatchError: If versions are incompatible + """ + # Check for version in response headers or body + api_version = response.headers.get('X-API-Version') + + if not api_version: + # Try to extract from response if it's token_info endpoint + try: + data = response.json() + api_version = data.get('apiVersion') + except Exception: + pass + + if api_version and not api_version.startswith(SUPPORTED_API_VERSION): + raise VersionMismatchError( + f"API version {api_version} not compatible with CLI {SUPPORTED_API_VERSION}", + f"Upgrade CLI: pip install --upgrade unityauth-cli" + ) diff --git a/unityauth-cli/src/unityauth_cli/commands/__init__.py b/unityauth-cli/src/unityauth_cli/commands/__init__.py new file mode 100644 index 0000000..c97c876 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/__init__.py @@ -0,0 +1 @@ +"""Command modules for UnityAuth CLI.""" diff --git a/unityauth-cli/src/unityauth_cli/commands/config.py b/unityauth-cli/src/unityauth_cli/commands/config.py new file mode 100644 index 0000000..b990736 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/config.py @@ -0,0 +1,222 @@ +"""Configuration management commands: show, set, edit. + +Handles CLI configuration file operations. +""" + +import os +import subprocess +import sys + +import click + +from unityauth_cli.cli import CLIContext, console, error, format_option, info, pass_context, success +from unityauth_cli.formatters.table import format_key_value_table +from unityauth_cli.formatters.json_fmt import format_json + + +@click.group() +def config() -> None: + """Manage CLI configuration settings. + + View and modify configuration stored in ~/.config/unityauth-cli/config.yml + + \b + Examples: + unityauth config show # Display current config + unityauth config set api_url https://auth.example.com + unityauth config set default_format json + unityauth config edit # Open in editor + """ + pass + + +@config.command() +@format_option +@pass_context +def show(ctx: CLIContext) -> None: + """Display current configuration settings. + + Shows all configuration values from the config file. + + \b + Examples: + unityauth config show + unityauth config show -o json + """ + try: + if not ctx.config: + error("Configuration not loaded") + sys.exit(4) + + # Get all config values + config_data = ctx.config.config + + # Format output based on format option + if ctx.output_format == 'json': + console.print(format_json(config_data)) + else: + # Flatten nested config for table display + flat_config = _flatten_config(config_data) + console.print(format_key_value_table(flat_config)) + + info(f"\nConfiguration file: {ctx.config.config_path}") + + except Exception as e: + from unityauth_cli.cli import handle_error + handle_error(e) + + +@config.command() +@click.argument('key') +@click.argument('value') +@pass_context +def set(ctx: CLIContext, key: str, value: str) -> None: + """Set a configuration value. + + KEY is the configuration key (supports dot notation like 'batch.max_size') + VALUE is the value to set + + \b + Examples: + unityauth config set api_url https://auth.example.com + unityauth config set default_format json + unityauth config set timeout 60 + unityauth config set batch.max_size 500 + unityauth config set batch.continue_on_error true + """ + try: + if not ctx.config: + error("Configuration not loaded") + sys.exit(4) + + # Convert value to appropriate type + converted_value = _convert_value(value) + + # Set the value + ctx.config.set(key, converted_value) + + # Save configuration + ctx.config.save() + + success(f"Configuration updated: {key} = {converted_value}") + + if ctx.verbose: + info(f"Configuration saved to {ctx.config.config_path}") + + except Exception as e: + from unityauth_cli.cli import handle_error + handle_error(e) + + +@config.command() +@pass_context +def edit(ctx: CLIContext) -> None: + """Open configuration file in default editor. + + Opens the YAML configuration file in your default text editor. + Falls back to vi/nano if no editor is configured. + + \b + Examples: + unityauth config edit + EDITOR=nano unityauth config edit + """ + try: + if not ctx.config: + error("Configuration not loaded") + sys.exit(4) + + config_path = ctx.config.config_path + + # Create config file if it doesn't exist + if not config_path.exists(): + ctx.config.save() + info(f"Created new configuration file at {config_path}") + + # Determine editor to use + editor = os.environ.get('EDITOR') or os.environ.get('VISUAL') + + if not editor: + # Try common editors + for candidate in ['nano', 'vi', 'vim', 'notepad']: + if subprocess.run(['which', candidate], capture_output=True).returncode == 0: + editor = candidate + break + + if not editor: + error( + "No text editor found", + "Set EDITOR environment variable: export EDITOR=nano" + ) + sys.exit(4) + + # Open editor + if ctx.verbose: + info(f"Opening {config_path} with {editor}...") + + result = subprocess.run([editor, str(config_path)]) + + if result.returncode == 0: + # Reload configuration to validate changes + ctx.config.load() + success("Configuration file updated") + else: + error(f"Editor exited with code {result.returncode}") + sys.exit(1) + + except Exception as e: + from unityauth_cli.cli import handle_error + handle_error(e) + + +def _flatten_config(config: dict, prefix: str = '') -> dict: + """Flatten nested configuration dictionary. + + Args: + config: Configuration dictionary + prefix: Key prefix for nested values + + Returns: + Flattened dictionary with dot-notation keys + """ + flat = {} + for key, value in config.items(): + full_key = f"{prefix}.{key}" if prefix else key + + if isinstance(value, dict): + flat.update(_flatten_config(value, full_key)) + else: + flat[full_key] = value + + return flat + + +def _convert_value(value: str): + """Convert string value to appropriate Python type. + + Args: + value: String value to convert + + Returns: + Converted value (int, bool, or str) + """ + # Try boolean + if value.lower() in ('true', 'yes', 'on', '1'): + return True + if value.lower() in ('false', 'no', 'off', '0'): + return False + + # Try integer + try: + return int(value) + except ValueError: + pass + + # Try float + try: + return float(value) + except ValueError: + pass + + # Return as string + return value diff --git a/unityauth-cli/src/unityauth_cli/commands/init.py b/unityauth-cli/src/unityauth_cli/commands/init.py new file mode 100644 index 0000000..a19aaa7 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/init.py @@ -0,0 +1,197 @@ +"""Init command: First-time setup wizard for UnityAuth CLI. + +Guides users through initial configuration and authentication. +""" + +import sys + +import click +import requests + +from unityauth_cli.cli import CLIContext, console, error, info, pass_context, success, warning +from unityauth_cli.config import Configuration +from unityauth_cli.utils.validation import validate_url, validate_email + + +@click.command() +@click.option( + '--api-url', + help='UnityAuth API endpoint URL (skip prompt)', +) +@click.option( + '--skip-login', + is_flag=True, + help='Skip the login step after configuration', +) +@click.option( + '--allow-http', + is_flag=True, + help='Allow insecure HTTP URLs (not recommended)', +) +@pass_context +def init(ctx: CLIContext, api_url: str | None, skip_login: bool, allow_http: bool) -> None: + """Initialize UnityAuth CLI with first-time setup wizard. + + Guides you through configuring the API endpoint and optionally + authenticating with your credentials. + + \b + Examples: + unityauth init # Interactive setup + unityauth init --api-url https://auth.example.com # Skip URL prompt + unityauth init --skip-login # Configure only, no login + """ + console.print() + console.print("[bold cyan]Welcome to UnityAuth CLI![/bold cyan]") + console.print("Let's get you set up.\n") + + # Step 1: Get API URL + if not api_url: + if not sys.stdin.isatty(): + error( + "API URL required in non-interactive mode", + "Use --api-url option to specify the UnityAuth API endpoint" + ) + sys.exit(4) + + # Show current value if configured + current_url = ctx.config.get('api_url') if ctx.config else None + if current_url: + info(f"Current API URL: {current_url}") + + api_url = click.prompt( + 'API URL', + default=current_url or 'https://auth.example.com', + type=str + ) + + # Validate URL format + api_url = api_url.strip().rstrip('/') + + if not validate_url(api_url, require_https=not allow_http): + if not allow_http and api_url.startswith('http://'): + error( + "HTTP URLs are not allowed by default (insecure)", + "Use HTTPS or add --allow-http flag if this is intentional" + ) + else: + error( + "Invalid URL format", + "URL must start with https:// (e.g., https://auth.example.com)" + ) + sys.exit(4) + + # Step 2: Test connection + info(f"Testing connection to {api_url}...") + + try: + # Try to reach the /keys endpoint (public, no auth required) + response = requests.get(f"{api_url}/keys", timeout=10) + + if response.ok: + success("Connection successful") + if ctx.verbose: + info(f"Server responded with status {response.status_code}") + else: + # Server responded but with error + warning(f"Server responded with status {response.status_code}") + if not click.confirm("Continue anyway?", default=False): + info("Setup cancelled") + sys.exit(0) + + except requests.ConnectionError: + error( + f"Could not connect to {api_url}", + "Check that the URL is correct and the server is running" + ) + if not click.confirm("Save configuration anyway?", default=False): + info("Setup cancelled") + sys.exit(0) + except requests.Timeout: + warning("Connection timed out") + if not click.confirm("Save configuration anyway?", default=False): + info("Setup cancelled") + sys.exit(0) + except requests.RequestException as e: + warning(f"Connection test failed: {e}") + if not click.confirm("Save configuration anyway?", default=False): + info("Setup cancelled") + sys.exit(0) + + # Step 3: Save configuration + try: + config = ctx.config or Configuration() + config.set('api_url', api_url) + config.save() + success(f"Configuration saved to {config.config_path}") + except Exception as e: + error(f"Failed to save configuration: {e}") + sys.exit(4) + + # Step 4: Optionally login + if not skip_login: + console.print() + if sys.stdin.isatty() and click.confirm("Would you like to log in now?", default=True): + console.print() + _do_login(api_url, config, ctx.verbose) + else: + info("Skipping login. Run 'unityauth login' when ready.") + + # Step 5: Show next steps + console.print() + console.print("[bold green]Setup complete![/bold green]") + console.print() + console.print("Next steps:") + console.print(" [dim]$[/dim] unityauth tenant list [dim]# List your tenants[/dim]") + console.print(" [dim]$[/dim] unityauth user list --tenant-id 1 [dim]# List users in tenant 1[/dim]") + console.print(" [dim]$[/dim] unityauth role list [dim]# List available roles[/dim]") + console.print(" [dim]$[/dim] unityauth --help [dim]# See all commands[/dim]") + console.print() + + +def _do_login(api_url: str, config: Configuration, verbose: bool) -> None: + """Perform login as part of init wizard. + + Args: + api_url: API endpoint URL + config: Configuration instance + verbose: Whether to show verbose output + """ + from unityauth_cli import auth + from unityauth_cli.client import UnityAuthAPIClient + + # Get email + email = click.prompt('Email', type=str) + + if not validate_email(email): + error("Invalid email format") + return + + # Get password + password = click.prompt('Password', hide_input=True, type=str) + + # Attempt login + if verbose: + info(f"Authenticating with {api_url}...") + + try: + timeout = config.get('timeout', 30) + client = UnityAuthAPIClient(api_url, timeout=timeout) + response = client.post('/api/login', data={ + 'username': email, + 'password': password + }) + + # Extract token from response + token = response.get('access_token') or response.get('accessToken') + if not token: + error("Login failed: No token in response") + return + + # Store token in keyring + auth.store_token(api_url, token) + success(f"Logged in as {email}") + + except Exception as e: + error(f"Login failed: {e}") + info("You can try again later with 'unityauth login'") diff --git a/unityauth-cli/src/unityauth_cli/commands/login.py b/unityauth-cli/src/unityauth_cli/commands/login.py new file mode 100644 index 0000000..abd85af --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/login.py @@ -0,0 +1,206 @@ +"""Authentication commands: login, logout, token-info. + +Handles user authentication and session management. +""" + +import sys +from typing import Optional + +import click + +from unityauth_cli import auth +from unityauth_cli.cli import ( + CLIContext, + console, + error, + format_option, + handle_error, + info, + pass_context, + require_auth, + require_config, + success, + warning, +) +from unityauth_cli.client import UnityAuthAPIClient +from unityauth_cli.formatters.table import format_key_value_table +from unityauth_cli.formatters.json_fmt import format_json +from unityauth_cli.utils.errors import ConfigurationError +from unityauth_cli.utils.validation import validate_email + + +@click.command() +@click.option( + '--email', + envvar='UNITYAUTH_EMAIL', + help='Email address for login', +) +@click.option( + '--password', + envvar='UNITYAUTH_PASSWORD', + help='Password (will prompt if not provided)', +) +@pass_context +@require_config +def login(ctx: CLIContext, email: Optional[str], password: Optional[str]) -> None: + """Authenticate with UnityAuth and store credentials. + + Logs in to UnityAuth using email and password, then securely stores + the JWT token in your operating system's credential manager. + + \b + Examples: + unityauth login # Interactive mode + unityauth login --email user@example.com # Prompt for password + UNITYAUTH_PASSWORD=secret unityauth login # Non-interactive + """ + try: + # Get email (interactive or from option) + if not email: + if not sys.stdin.isatty(): + raise ConfigurationError( + "Email required in non-interactive mode", + "Use --email option or set UNITYAUTH_EMAIL environment variable" + ) + email = click.prompt('Email', type=str) + + # Validate email format + if not validate_email(email): + error("Invalid email format") + sys.exit(1) + + # Get password (interactive or from option) + if not password: + if not sys.stdin.isatty(): + raise ConfigurationError( + "Password required in non-interactive mode", + "Use --password option or set UNITYAUTH_PASSWORD environment variable" + ) + password = click.prompt('Password', hide_input=True, type=str) + + # Make login request + if ctx.verbose: + info(f"Authenticating with {ctx.api_url}...") + + # Get timeout from config if available + timeout = 30 + if ctx.config: + timeout = ctx.config.get('timeout', 30) + + client = UnityAuthAPIClient(ctx.api_url, timeout=timeout) + response = client.post('/api/login', data={ + 'username': email, + 'password': password + }) + + # Extract token from response + token = response.get('access_token') or response.get('accessToken') + if not token: + error("Login failed: No token in response") + sys.exit(1) + + # Store token in keyring + auth.store_token(ctx.api_url, token) + + success(f"Login successful as {email}") + + if ctx.verbose: + info("Token stored securely in OS credential manager") + + except Exception as e: + handle_error(e) + + +@click.command() +@pass_context +@require_config +def logout(ctx: CLIContext) -> None: + """Remove stored credentials and logout. + + Deletes the stored JWT token from your operating system's credential manager. + + \b + Examples: + unityauth logout + """ + try: + # Check if token exists + if not auth.has_token(ctx.api_url): + warning("No active session found") + return + + # Delete token from keyring + auth.delete_token(ctx.api_url) + + success("Logout successful") + + if ctx.verbose: + info("Token removed from OS credential manager") + + except Exception as e: + handle_error(e) + + +@click.command(name='token-info') +@format_option +@pass_context +@require_auth +def token_info(ctx: CLIContext, client: UnityAuthAPIClient) -> None: + """Display current session and token information. + + Shows details about the currently authenticated session, including + user email, token expiration, and API version. + + \b + Examples: + unityauth token-info + unityauth token-info -o json + """ + try: + # Make token_info request + if ctx.verbose: + info(f"Fetching token info from {ctx.api_url}...") + + response = client.get('/api/token_info') + + # Format output based on format option + if ctx.output_format == 'json': + console.print(format_json(response)) + else: + # Extract email from various possible fields + email = ( + response.get('username') or + response.get('sub') or + response.get('email') or + response.get('userEmail', 'N/A') + ) + + # Extract name if available + first_name = response.get('first_name', '') + last_name = response.get('last_name', '') + full_name = f"{first_name} {last_name}".strip() if first_name or last_name else None + + # Build display data + display_data = { + 'Email': email, + } + + if full_name: + display_data['Name'] = full_name + + display_data['API Endpoint'] = ctx.api_url + display_data['Authenticated'] = 'Yes' + + # Add expiration if present + if 'exp' in response or 'expiration' in response: + exp = response.get('exp') or response.get('expiration') + display_data['Token Expires'] = exp + + # Add API version if present + if 'apiVersion' in response: + display_data['API Version'] = response['apiVersion'] + + console.print(format_key_value_table(display_data)) + + except Exception as e: + handle_error(e) diff --git a/unityauth-cli/src/unityauth_cli/commands/permissions.py b/unityauth-cli/src/unityauth_cli/commands/permissions.py new file mode 100644 index 0000000..2240258 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/permissions.py @@ -0,0 +1,112 @@ +"""Permission commands: list, check. + +Handles permission discovery and verification operations. +""" + +import sys + +import click + +from unityauth_cli.cli import ( + CLIContext, + console, + error, + format_option, + handle_error, + info, + pass_context, + require_auth, + success, + warning, +) +from unityauth_cli.client import UnityAuthAPIClient +from unityauth_cli.formatters.table import format_table +from unityauth_cli.formatters.json_fmt import format_json +from unityauth_cli.formatters.csv_fmt import format_csv +from unityauth_cli.utils.errors import AuthorizationError, ValidationError + + +@click.command('list') +@click.option('-t', '--tenant-id', required=True, type=int, help='Tenant ID to check permissions for') +@click.option('-s', '--service-id', required=True, type=int, help='Service ID to check permissions for') +@format_option +@pass_context +@require_auth +def list_permissions( + ctx: CLIContext, + tenant_id: int, + service_id: int, + client: UnityAuthAPIClient, +) -> None: + """List your permissions for a tenant and service. + + Returns all permissions the authenticated user has for the specified + tenant and service combination. + + \b + Finding IDs: + - Tenant IDs: Run 'unityauth tenant list' to see available tenants + - Service IDs: Assigned by system administrator (common: 1=Libre311) + + \b + Examples: + unityauth permissions list --tenant-id 1 --service-id 1 + unityauth permissions list --tenant-id 1 --service-id 1 -o json + """ + try: + # Validate IDs + if tenant_id <= 0: + raise ValidationError("Tenant ID must be a positive integer") + if service_id <= 0: + raise ValidationError("Service ID must be a positive integer") + + if ctx.verbose: + info(f"Fetching permissions for tenant {tenant_id}, service {service_id}...") + + # Build request payload + payload = { + 'tenantId': tenant_id, + 'serviceId': service_id, + } + + # Make POST request to get permissions + response = client.post('/api/principal/permissions', data=payload) + + # Handle error response (Failure case) + if response and 'errorMessage' in response: + error(f"Failed to get permissions: {response['errorMessage']}") + sys.exit(1) + + # Handle success response + permissions = response.get('permissions', []) if response else [] + + if not permissions: + warning("No permissions found for this tenant/service combination") + return + + # Format and display output based on format option + if ctx.output_format == 'json': + console.print(format_json({'permissions': permissions})) + elif ctx.output_format == 'csv': + # For CSV, create a simple list format + headers = ['permission'] + rows = [{'permission': perm} for perm in permissions] + console.print(format_csv(rows, headers)) + else: + # Table format (default) + headers = ['Permission'] + rows = [[perm] for perm in permissions] + console.print(format_table(rows, headers)) + + if ctx.verbose: + info(f"Total permissions: {len(permissions)}") + + except AuthorizationError as e: + error( + str(e), + "You may not have access to this tenant/service combination.\n" + "Contact your administrator for access." + ) + sys.exit(3) + except Exception as e: + handle_error(e) diff --git a/unityauth-cli/src/unityauth_cli/commands/roles.py b/unityauth-cli/src/unityauth_cli/commands/roles.py new file mode 100644 index 0000000..eff874f --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/roles.py @@ -0,0 +1,97 @@ +"""Role discovery commands: list. + +Handles role listing and discovery operations. +""" + +import sys + +import click + +from unityauth_cli.cli import ( + CLIContext, + console, + error, + format_option, + handle_error, + info, + pass_context, + require_auth, + warning, +) +from unityauth_cli.client import UnityAuthAPIClient +from unityauth_cli.formatters.table import format_table +from unityauth_cli.formatters.json_fmt import format_json +from unityauth_cli.formatters.csv_fmt import format_csv +from unityauth_cli.utils.errors import AuthorizationError + + +@click.command('list') +@format_option +@pass_context +@require_auth +def list_roles(ctx: CLIContext, client: UnityAuthAPIClient) -> None: + """List all available roles. + + Lists all roles defined in the system with their descriptions. + Requires Unity Administrator or Tenant Administrator permissions. + + \b + Role IDs are used in: + - 'unityauth user create --role-ids "1,2"' + - 'unityauth user update --role-ids "1,2"' + + \b + Examples: + unityauth role list + unityauth role list -o json + unityauth role list -o csv + """ + try: + if ctx.verbose: + info("Fetching available roles...") + + response = client.get('/api/roles') + + # Handle empty response + if not response: + warning("No roles found") + return + + # Ensure response is a list + roles = response if isinstance(response, list) else [response] + + if not roles: + warning("No roles found") + return + + # Format and display output based on format option + if ctx.output_format == 'json': + console.print(format_json(roles)) + elif ctx.output_format == 'csv': + headers = ['id', 'name', 'description'] + console.print(format_csv(roles, headers)) + else: + # Table format (default) + headers = ['ID', 'Name', 'Description'] + rows = [ + [ + role.get('id', 'N/A'), + role.get('name', 'N/A'), + role.get('description', '') or 'N/A' + ] + for role in roles + ] + console.print(format_table(rows, headers)) + + if ctx.verbose: + info(f"Total roles: {len(roles)}") + + except AuthorizationError as e: + error( + str(e), + "You need Unity Administrator or Tenant Administrator permissions to list roles.\n" + "Contact your administrator to grant required permissions." + ) + sys.exit(3) + except Exception as e: + handle_error(e) diff --git a/unityauth-cli/src/unityauth_cli/commands/tenants.py b/unityauth-cli/src/unityauth_cli/commands/tenants.py new file mode 100644 index 0000000..0cd3405 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/tenants.py @@ -0,0 +1,173 @@ +"""Tenant discovery commands: list, users. + +Handles tenant listing and user discovery operations. +""" + +import sys + +import click + +from unityauth_cli.cli import ( + CLIContext, + console, + error, + format_option, + handle_error, + info, + pass_context, + require_auth, + warning, +) +from unityauth_cli.client import UnityAuthAPIClient +from unityauth_cli.formatters.table import format_table +from unityauth_cli.formatters.json_fmt import format_json +from unityauth_cli.formatters.csv_fmt import format_csv +from unityauth_cli.utils.errors import AuthorizationError, ValidationError + + +@click.command('list') +@format_option +@pass_context +@require_auth +def list_tenants(ctx: CLIContext, client: UnityAuthAPIClient) -> None: + """List accessible tenants. + + Lists all tenants accessible to the current user. + Unity Administrators see all tenants. + Tenant Administrators see only their assigned tenants. + + \b + Tenant IDs are used in: + - 'unityauth user list --tenant-id ' + - 'unityauth user create --tenant-id ' + - 'unityauth permissions list --tenant-id ' + + \b + Examples: + unityauth tenant list + unityauth tenant list -o json + unityauth tenant list -o csv + """ + try: + if ctx.verbose: + info("Fetching accessible tenants...") + + response = client.get('/api/tenants') + + # Handle empty response + if not response: + warning("No tenants found") + return + + # Ensure response is a list + tenants = response if isinstance(response, list) else [response] + + if not tenants: + warning("No tenants found") + return + + # Format and display output based on format option + if ctx.output_format == 'json': + console.print(format_json(tenants)) + elif ctx.output_format == 'csv': + headers = ['id', 'name'] + console.print(format_csv(tenants, headers)) + else: + # Table format (default) + headers = ['ID', 'Name'] + rows = [ + [tenant.get('id', 'N/A'), tenant.get('name', 'N/A')] + for tenant in tenants + ] + console.print(format_table(rows, headers)) + + if ctx.verbose: + info(f"Total tenants: {len(tenants)}") + + except AuthorizationError as e: + error( + str(e), + "You need Unity Administrator or Tenant Administrator permissions to list tenants.\n" + "Contact your administrator to grant required permissions." + ) + sys.exit(3) + except Exception as e: + handle_error(e) + + +@click.command('users') +@click.argument('tenant_id', type=int) +@format_option +@pass_context +@require_auth +def tenant_users(ctx: CLIContext, tenant_id: int, client: UnityAuthAPIClient) -> None: + """List users in a tenant. + + Lists all users belonging to the specified tenant. + Requires appropriate permissions to view tenant users. + + \b + Examples: + unityauth tenant users 1 + unityauth tenant users 1 -o json + unityauth tenant users 2 -o csv + """ + try: + # Validate tenant ID + if tenant_id <= 0: + raise ValidationError("Tenant ID must be a positive integer") + + if ctx.verbose: + info(f"Fetching users for tenant {tenant_id}...") + + response = client.get(f'/api/tenants/{tenant_id}/users') + + # Handle empty response + if not response: + warning(f"No users found in tenant {tenant_id}") + return + + # Ensure response is a list + users = response if isinstance(response, list) else [response] + + if not users: + warning(f"No users found in tenant {tenant_id}") + return + + # Format and display output based on format option + if ctx.output_format == 'json': + console.print(format_json(users)) + elif ctx.output_format == 'csv': + headers = ['id', 'email', 'firstName', 'lastName', 'roles'] + console.print(format_csv(users, headers)) + else: + # Table format (default) + headers = ['ID', 'Email', 'First Name', 'Last Name', 'Roles'] + rows = [] + for user in users: + roles = user.get('roles', []) + if isinstance(roles, list): + roles_str = ', '.join(str(r) for r in roles) + else: + roles_str = str(roles) if roles else 'None' + rows.append([ + user.get('id', 'N/A'), + user.get('email', 'N/A'), + user.get('firstName', 'N/A'), + user.get('lastName', 'N/A'), + roles_str if roles_str else 'None' + ]) + console.print(format_table(rows, headers)) + + if ctx.verbose: + info(f"Total users: {len(users)}") + + except AuthorizationError as e: + error( + str(e), + "You need appropriate permissions to list tenant users.\n" + "Contact your administrator to grant required permissions." + ) + sys.exit(3) + except Exception as e: + handle_error(e) diff --git a/unityauth-cli/src/unityauth_cli/commands/users.py b/unityauth-cli/src/unityauth_cli/commands/users.py new file mode 100644 index 0000000..bdd84de --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/commands/users.py @@ -0,0 +1,443 @@ +"""User management commands: create, update, list. + +Handles user account provisioning and management operations. +""" + +import sys + +import click + +from unityauth_cli.cli import ( + CLIContext, + console, + error, + format_option, + handle_error, + info, + pass_context, + require_auth, + success, + warning, +) +from unityauth_cli.client import UnityAuthAPIClient +from unityauth_cli.formatters.table import format_table +from unityauth_cli.formatters.json_fmt import format_json +from unityauth_cli.formatters.csv_fmt import format_csv +from unityauth_cli.utils.errors import AuthorizationError, ValidationError +from unityauth_cli.utils.validation import validate_email + + +@click.command() +@click.option('--email', required=True, help='User email address') +@click.option('--first-name', required=True, help='User first name') +@click.option('--last-name', required=True, help='User last name') +@click.option('--password', required=True, help='User password (min 8 characters)') +@click.option('-t', '--tenant-id', required=True, type=int, help='Tenant ID for the user') +@click.option('-r', '--role-ids', required=True, help='Comma-separated role IDs (e.g., "1,2,3")') +@click.option('--dry-run', '-n', is_flag=True, help='Preview changes without executing') +@pass_context +@require_auth +def create( + ctx: CLIContext, + email: str, + first_name: str, + last_name: str, + password: str, + tenant_id: int, + role_ids: str, + dry_run: bool, + client: UnityAuthAPIClient, +) -> None: + """Create a new user account. + + Creates a new user with the specified details and assigns roles. + Requires Unity Administrator or Tenant Administrator permissions. + + \b + Finding IDs: + - Tenant IDs: Run 'unityauth tenant list' + - Role IDs: Run 'unityauth role list' + + \b + Examples: + unityauth user create --email user@example.com --first-name John --last-name Doe --password MyP@ss123 --tenant-id 1 --role-ids "2" + unityauth user create --email admin@example.com --first-name Jane --last-name Admin --password SecureP@ss --tenant-id 1 --role-ids "1,2" + """ + try: + # Validate email format + if not validate_email(email): + raise ValidationError("Invalid email format") + + # Validate password length + if len(password) < 8: + raise ValidationError("Password must be at least 8 characters") + + # Validate name lengths + if not first_name or len(first_name) > 100: + raise ValidationError("First name must be 1-100 characters") + + if not last_name or len(last_name) > 100: + raise ValidationError("Last name must be 1-100 characters") + + # Validate tenant ID + if tenant_id <= 0: + raise ValidationError("Tenant ID must be a positive integer") + + # Parse role IDs + try: + roles = [int(rid.strip()) for rid in role_ids.split(',')] + except ValueError: + raise ValidationError("Role IDs must be comma-separated integers") + + if not roles: + raise ValidationError("At least one role ID must be provided") + + # Build request payload + payload = { + 'email': email, + 'firstName': first_name, + 'lastName': last_name, + 'password': password, + 'tenantId': tenant_id, + 'roles': roles, + } + + # Handle dry-run mode + if dry_run: + console.print("\n[bold cyan][DRY RUN][/bold cyan] Would create user:") + console.print(f" Email: {email}") + console.print(f" First Name: {first_name}") + console.print(f" Last Name: {last_name}") + console.print(f" Tenant ID: {tenant_id}") + console.print(f" Role IDs: {', '.join(str(r) for r in roles)}") + console.print("\nRun without --dry-run to execute.") + return + + # Make create request + if ctx.verbose: + info(f"Creating user {email} in tenant {tenant_id}...") + + response = client.post('/api/users', data=payload) + + # Extract user ID from response + user_id = response.get('id') if response else None + + if user_id: + success(f"User created successfully (ID: {user_id})") + else: + success(f"User created successfully: {email}") + + if ctx.verbose and response: + info(f"User details: {response}") + + except ValidationError as e: + error_msg = str(e) + # Provide specific guidance for common errors + if "already exists" in error_msg.lower() or ("invalid request" in error_msg.lower() and "bad request" in error_msg.lower()): + error( + f"User creation failed: {error_msg}", + f"This typically means user '{email}' already exists in tenant {tenant_id}.\n\n" + f"Options:\n" + f" • Update existing user: Get user ID with 'unityauth user list --tenant-id {tenant_id}',\n" + f" then run 'unityauth user update --role-ids \"1,2\"'\n" + f" • Use different email: Try a different email address\n" + f" • Use different tenant: Change --tenant-id to create user in another tenant" + ) + else: + error(error_msg) + sys.exit(1) + except AuthorizationError as e: + error( + str(e), + "You need Unity Administrator or Tenant Administrator permissions to create users.\n" + "Contact your administrator to grant required permissions." + ) + sys.exit(3) + except Exception as e: + handle_error(e) + + +@click.command() +@click.argument('user_id', type=int) +@click.option('-t', '--tenant-id', required=True, type=int, help='Tenant ID where user has roles') +@click.option('-r', '--role-ids', required=True, help='Comma-separated role IDs to assign (e.g., "1,2,3")') +@click.option('--dry-run', '-n', is_flag=True, help='Preview changes without executing') +@pass_context +@require_auth +def update( + ctx: CLIContext, + user_id: int, + tenant_id: int, + role_ids: str, + dry_run: bool, + client: UnityAuthAPIClient, +) -> None: + """Update user roles in a tenant. + + Updates the role assignments for an existing user in a specific tenant. + This replaces all current roles with the specified roles for that tenant. + + \b + Finding IDs: + - User IDs: Run 'unityauth user list --tenant-id ' + - Tenant IDs: Run 'unityauth tenant list' + - Role IDs: Run 'unityauth role list' + + \b + Examples: + unityauth user update 5 --tenant-id 1 --role-ids "1,2" + unityauth user update 10 --tenant-id 1 --role-ids "3" + """ + try: + # Validate user ID + if user_id <= 0: + raise ValidationError("User ID must be a positive integer") + + # Validate tenant ID + if tenant_id <= 0: + raise ValidationError("Tenant ID must be a positive integer") + + # Parse role IDs + try: + roles = [int(rid.strip()) for rid in role_ids.split(',')] + except ValueError: + raise ValidationError("Role IDs must be comma-separated integers") + + if not roles: + raise ValidationError("At least one role ID must be provided") + + # Build request payload - backend requires tenantId and roles + payload = { + 'tenantId': tenant_id, + 'roles': roles + } + + # Handle dry-run mode + if dry_run: + console.print("\n[bold cyan][DRY RUN][/bold cyan] Would update user roles:") + console.print(f" User ID: {user_id}") + console.print(f" Tenant ID: {tenant_id}") + console.print(f" New Role IDs: {', '.join(str(r) for r in roles)}") + console.print("\nRun without --dry-run to execute.") + return + + # Make update request - use PATCH to /api/users/{id}/roles endpoint + if ctx.verbose: + info(f"Updating user {user_id} roles in tenant {tenant_id} to {roles}...") + + result = client.patch(f'/api/users/{user_id}/roles', data=payload) + + success(f"User {user_id} roles updated successfully in tenant {tenant_id}") + + if ctx.verbose and result: + info(f"Updated user: {result}") + + except AuthorizationError as e: + error( + str(e), + "You need Unity Administrator or Tenant Administrator permissions to update users.\n" + "Contact your administrator to grant required permissions." + ) + sys.exit(3) + except Exception as e: + handle_error(e) + + +@click.command('update-profile') +@click.argument('user_id', type=int) +@click.option('--first-name', help='New first name') +@click.option('--last-name', help='New last name') +@click.option('--password', help='New password (min 8 characters)') +@click.option('--dry-run', '-n', is_flag=True, help='Preview changes without executing') +@pass_context +@require_auth +def update_profile( + ctx: CLIContext, + user_id: int, + first_name: str | None, + last_name: str | None, + password: str | None, + dry_run: bool, + client: UnityAuthAPIClient, +) -> None: + """Update your own user profile. + + Updates your profile information (first name, last name, password). + You can only update your own profile - the user ID must match + the authenticated user. + + At least one field must be provided. + + \b + Finding your user ID: + Run 'unityauth token-info' to see your user ID. + + \b + Examples: + unityauth user update-profile 5 --first-name John + unityauth user update-profile 5 --last-name Smith + unityauth user update-profile 5 --password NewSecureP@ss123 + unityauth user update-profile 5 --first-name John --last-name Smith --password NewP@ss + """ + try: + # Validate user ID + if user_id <= 0: + raise ValidationError("User ID must be a positive integer") + + # Ensure at least one field is provided + if not any([first_name, last_name, password]): + raise ValidationError( + "At least one field must be provided: --first-name, --last-name, or --password" + ) + + # Validate password length if provided + if password and len(password) < 8: + raise ValidationError("Password must be at least 8 characters") + + # Validate name lengths if provided + if first_name and len(first_name) > 100: + raise ValidationError("First name must be 1-100 characters") + + if last_name and len(last_name) > 100: + raise ValidationError("Last name must be 1-100 characters") + + # Build request payload - only include non-None fields + payload = {} + if first_name: + payload['firstName'] = first_name + if last_name: + payload['lastName'] = last_name + if password: + payload['password'] = password + + # Handle dry-run mode + if dry_run: + console.print("\n[bold cyan][DRY RUN][/bold cyan] Would update profile:") + console.print(f" User ID: {user_id}") + if first_name: + console.print(f" First Name: {first_name}") + if last_name: + console.print(f" Last Name: {last_name}") + if password: + console.print(" Password: ********") + console.print("\nRun without --dry-run to execute.") + return + + # Make update request + if ctx.verbose: + fields = ', '.join(payload.keys()) + info(f"Updating profile for user {user_id} (fields: {fields})...") + + result = client.patch(f'/api/users/{user_id}', data=payload) + + success(f"Profile updated successfully for user {user_id}") + + if ctx.verbose and result: + info(f"Updated user: {result}") + + except AuthorizationError as e: + error( + str(e), + "You can only update your own profile. The user ID must match your authenticated user.\n" + "Use 'unityauth token-info' to see your user details." + ) + sys.exit(3) + except ValidationError as e: + error_msg = str(e).lower() + # Check for user ID mismatch error from backend + if "mismatch" in error_msg or "bad request" in error_msg: + error( + "User ID mismatch: You can only update your own profile", + f"The user ID {user_id} does not match your authenticated user.\n\n" + "To find your user ID:\n" + " $ unityauth token-info\n\n" + "Note: 'update-profile' is a self-service command. To update another\n" + "user's details, an administrator must use the web interface or API directly." + ) + else: + error(str(e)) + sys.exit(1) + except Exception as e: + handle_error(e) + + +@click.command() +@click.option('-t', '--tenant-id', type=int, required=True, help='Tenant ID to list users from') +@format_option +@pass_context +@require_auth +def list_users(ctx: CLIContext, tenant_id: int, client: UnityAuthAPIClient) -> None: + """List users in a tenant. + + Lists all users in the specified tenant. Requires Unity Administrator + or Tenant Administrator permissions for the target tenant. + + \b + Finding IDs: + - Tenant IDs: Run 'unityauth tenant list' + + \b + Examples: + unityauth user list --tenant-id 1 + unityauth user list --tenant-id 1 -o json + unityauth user list --tenant-id 1 -o csv + """ + try: + # Validate tenant ID + if tenant_id <= 0: + raise ValidationError("Tenant ID must be a positive integer") + + # Make list request + if ctx.verbose: + info(f"Fetching users for tenant {tenant_id}...") + + # Build endpoint - backend uses /api/tenants/{id}/users + endpoint = f'/api/tenants/{tenant_id}/users' + + response = client.get(endpoint) + + # Handle empty response + if not response: + warning("No users found") + return + + # Ensure response is a list + users = response if isinstance(response, list) else [response] + + # Format and display output based on format option + if ctx.output_format == 'json': + console.print(format_json(users)) + elif ctx.output_format == 'csv': + # Define CSV headers + headers = ['id', 'email', 'firstName', 'lastName', 'roles'] + console.print(format_csv(users, headers)) + else: + # Table format (default) + headers = ['ID', 'Email', 'First Name', 'Last Name', 'Roles'] + + # Transform data for table display + rows = [] + for user in users: + roles_str = ', '.join(str(r) for r in user.get('roles', [])) + rows.append([ + user.get('id', 'N/A'), + user.get('email', 'N/A'), + user.get('firstName', 'N/A'), + user.get('lastName', 'N/A'), + roles_str if roles_str else 'None' + ]) + + console.print(format_table(rows, headers)) + + if ctx.verbose: + info(f"Total users: {len(users)}") + + except AuthorizationError as e: + error( + str(e), + "You need appropriate permissions to list users.\n" + "Contact your administrator to grant required permissions." + ) + sys.exit(3) + except Exception as e: + handle_error(e) diff --git a/unityauth-cli/src/unityauth_cli/config.py b/unityauth-cli/src/unityauth_cli/config.py new file mode 100644 index 0000000..9f09b1f --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/config.py @@ -0,0 +1,143 @@ +"""Configuration management for UnityAuth CLI. + +Handles loading and saving configuration from YAML files. +""" + +import os +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + +from unityauth_cli.utils.errors import ConfigurationError + + +class Configuration: + """Manages CLI configuration settings. + + Configuration is stored in a YAML file at: + - Linux/macOS: ~/.config/unityauth-cli/config.yml + - Windows: %APPDATA%/unityauth-cli/config.yml + """ + + DEFAULT_CONFIG = { + 'api_url': None, + 'api_version': '1.0', + 'default_format': 'table', + 'timeout': 30, + 'batch': { + 'max_size': 1000, + 'continue_on_error': True, + 'delay_ms': 0, + }, + 'output': { + 'show_headers': True, + 'table_style': 'grid', + 'color_enabled': True, + } + } + + def __init__(self, config_path: Optional[Path] = None) -> None: + """Initialize configuration. + + Args: + config_path: Optional custom config file path. If None, uses default location. + """ + if config_path is None: + config_path = self._get_default_config_path() + + self.config_path = config_path + self.config: Dict[str, Any] = self.DEFAULT_CONFIG.copy() + self.load() + + @staticmethod + def _get_default_config_path() -> Path: + """Get the default configuration file path for the current OS.""" + if os.name == 'nt': # Windows + config_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming')) + else: # Linux/macOS + config_dir = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) + + return config_dir / 'unityauth-cli' / 'config.yml' + + def load(self) -> None: + """Load configuration from file. + + If the file doesn't exist, uses default configuration. + """ + if not self.config_path.exists(): + return + + try: + with open(self.config_path, 'r', encoding='utf-8') as f: + loaded_config = yaml.safe_load(f) or {} + # Merge with defaults (preserve defaults for missing keys) + self._deep_merge(self.config, loaded_config) + except (yaml.YAMLError, IOError) as e: + raise ConfigurationError(f"Failed to load configuration from {self.config_path}: {e}") + + def save(self) -> None: + """Save current configuration to file. + + Creates the config directory if it doesn't exist. + """ + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(self.config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(self.config, f, default_flow_style=False, sort_keys=False) + except IOError as e: + raise ConfigurationError(f"Failed to save configuration to {self.config_path}: {e}") + + def get(self, key: str, default: Any = None) -> Any: + """Get a configuration value. + + Args: + key: Configuration key (supports dot notation, e.g., 'batch.max_size') + default: Default value if key doesn't exist + + Returns: + Configuration value or default + """ + keys = key.split('.') + value = self.config + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def set(self, key: str, value: Any) -> None: + """Set a configuration value. + + Args: + key: Configuration key (supports dot notation, e.g., 'batch.max_size') + value: Value to set + """ + keys = key.split('.') + config = self.config + + # Navigate to the parent dictionary + for k in keys[:-1]: + if k not in config or not isinstance(config[k], dict): + config[k] = {} + config = config[k] + + # Set the final value + config[keys[-1]] = value + + def _deep_merge(self, base: Dict[str, Any], override: Dict[str, Any]) -> None: + """Deep merge override dictionary into base dictionary. + + Args: + base: Base dictionary to merge into + override: Dictionary with values to override + """ + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._deep_merge(base[key], value) + else: + base[key] = value diff --git a/unityauth-cli/src/unityauth_cli/formatters/__init__.py b/unityauth-cli/src/unityauth_cli/formatters/__init__.py new file mode 100644 index 0000000..2ad8b54 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/formatters/__init__.py @@ -0,0 +1 @@ +"""Output formatters for UnityAuth CLI.""" diff --git a/unityauth-cli/src/unityauth_cli/formatters/csv_fmt.py b/unityauth-cli/src/unityauth_cli/formatters/csv_fmt.py new file mode 100644 index 0000000..159b898 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/formatters/csv_fmt.py @@ -0,0 +1,63 @@ +"""CSV formatter for spreadsheet-compatible output. + +Provides RFC 4180 compliant CSV output. +""" + +import csv +import io +from typing import Any, List, Dict + + +def format_csv(data: List[Dict[str, Any]], fieldnames: List[str] = None) -> str: + """Format data as CSV. + + Args: + data: List of dictionaries to format + fieldnames: Optional list of field names for header row. + If None, uses keys from first dictionary. + + Returns: + CSV-formatted string + """ + if not data: + return "" + + # Determine fieldnames from first row if not provided + if fieldnames is None: + if isinstance(data[0], dict): + fieldnames = list(data[0].keys()) + else: + raise ValueError("Cannot determine CSV fieldnames from data") + + # Use StringIO to write CSV in memory + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=fieldnames, lineterminator='\n') + + # Write header and rows + writer.writeheader() + writer.writerows(data) + + return output.getvalue() + + +def format_csv_from_rows(rows: List[List[Any]], header: List[str] = None) -> str: + """Format list of rows as CSV. + + Args: + rows: List of lists (each inner list is a row) + header: Optional header row + + Returns: + CSV-formatted string + """ + output = io.StringIO() + writer = csv.writer(output, lineterminator='\n') + + # Write header if provided + if header: + writer.writerow(header) + + # Write data rows + writer.writerows(rows) + + return output.getvalue() diff --git a/unityauth-cli/src/unityauth_cli/formatters/json_fmt.py b/unityauth-cli/src/unityauth_cli/formatters/json_fmt.py new file mode 100644 index 0000000..bf46de7 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/formatters/json_fmt.py @@ -0,0 +1,32 @@ +"""JSON formatter for machine-readable output. + +Provides JSON output suitable for piping to jq or other tools. +""" + +import json +from typing import Any + + +def format_json(data: Any, indent: int = 2) -> str: + """Format data as pretty-printed JSON. + + Args: + data: Data to format (must be JSON-serializable) + indent: Number of spaces for indentation (default: 2) + + Returns: + Formatted JSON string + """ + return json.dumps(data, indent=indent, ensure_ascii=False) + + +def format_json_compact(data: Any) -> str: + """Format data as compact JSON (no whitespace). + + Args: + data: Data to format (must be JSON-serializable) + + Returns: + Compact JSON string + """ + return json.dumps(data, separators=(',', ':'), ensure_ascii=False) diff --git a/unityauth-cli/src/unityauth_cli/formatters/table.py b/unityauth-cli/src/unityauth_cli/formatters/table.py new file mode 100644 index 0000000..e7efc49 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/formatters/table.py @@ -0,0 +1,72 @@ +"""Table formatter using tabulate library. + +Provides human-readable ASCII/Unicode table output. +""" + +from typing import Any, List, Dict + +from tabulate import tabulate + + +def format_table( + data: List[Dict[str, Any]], + headers: str = "keys", + table_format: str = "grid", + show_headers: bool = True +) -> str: + """Format data as a table. + + Args: + data: List of dictionaries to display + headers: Header mode - "keys" uses dict keys, "firstrow" uses first row, + or list of custom headers (default: "keys") + table_format: Table style - "grid", "simple", "plain", "fancy_grid", etc. + (default: "grid") + show_headers: Whether to show column headers (default: True) + + Returns: + Formatted table as string + """ + if not data: + return "No data to display" + + # If headers are disabled, use empty list + if not show_headers: + headers = [] + + return tabulate(data, headers=headers, tablefmt=table_format) + + +def format_key_value_table(data: Dict[str, Any], table_format: str = "grid") -> str: + """Format key-value pairs as a two-column table. + + Args: + data: Dictionary of key-value pairs + table_format: Table style (default: "grid") + + Returns: + Formatted table as string + """ + if not data: + return "No data to display" + + # Convert to list of [key, value] pairs + rows = [[key, value] for key, value in data.items()] + + return tabulate(rows, headers=["Field", "Value"], tablefmt=table_format) + + +def format_list(items: List[str], prefix: str = "- ") -> str: + """Format a list of items with a prefix. + + Args: + items: List of strings to format + prefix: Prefix for each line (default: "- ") + + Returns: + Formatted list as string + """ + if not items: + return "No items to display" + + return "\n".join(f"{prefix}{item}" for item in items) diff --git a/unityauth-cli/src/unityauth_cli/utils/__init__.py b/unityauth-cli/src/unityauth_cli/utils/__init__.py new file mode 100644 index 0000000..8f54eeb --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for UnityAuth CLI.""" diff --git a/unityauth-cli/src/unityauth_cli/utils/errors.py b/unityauth-cli/src/unityauth_cli/utils/errors.py new file mode 100644 index 0000000..26df748 --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/utils/errors.py @@ -0,0 +1,128 @@ +"""Custom exception hierarchy for UnityAuth CLI. + +All CLI-specific exceptions with associated exit codes. +""" + + +class UnityAuthCLIError(Exception): + """Base exception for all CLI errors. + + All custom exceptions should inherit from this base class. + """ + exit_code = 1 + + def __init__(self, message: str, details: str = "") -> None: + """Initialize error with message and optional details. + + Args: + message: Primary error message + details: Additional context or guidance for the user + """ + self.message = message + self.details = details + super().__init__(message) + + +class AuthenticationError(UnityAuthCLIError): + """Authentication failed (invalid credentials, expired token). + + Exit code 2 indicates the user needs to run 'unityauth login'. + """ + exit_code = 2 + + +class AuthorizationError(UnityAuthCLIError): + """Insufficient permissions for operation. + + Exit code 3 indicates the user lacks required permissions. + + Note: Named AuthorizationError (not PermissionError) to avoid + shadowing Python's built-in PermissionError. + """ + exit_code = 3 + + +# Alias for backwards compatibility +PermissionError = AuthorizationError + + +class ConfigurationError(UnityAuthCLIError): + """Invalid configuration (missing API endpoint, invalid config). + + Exit code 4 indicates configuration problems. + """ + exit_code = 4 + + +class ValidationError(UnityAuthCLIError): + """Input validation failed. + + Exit code 1 for general validation errors. + """ + exit_code = 1 + + +class NetworkError(UnityAuthCLIError): + """Network connectivity issue. + + Exit code 1 for network-related errors. + """ + exit_code = 1 + + +class RateLimitError(UnityAuthCLIError): + """API rate limit exceeded. + + Exit code 1 with retry guidance. + """ + exit_code = 1 + + def __init__(self, message: str, retry_after: int = 60) -> None: + """Initialize rate limit error. + + Args: + message: Error message + retry_after: Seconds to wait before retrying + """ + self.retry_after = retry_after + super().__init__( + message, + f"Wait {retry_after} seconds and try again" + ) + + +class NotFoundError(UnityAuthCLIError): + """Resource not found. + + Exit code 1 for missing resources. + """ + exit_code = 1 + + +class ServerError(UnityAuthCLIError): + """Server-side error (5xx responses). + + Exit code 1 with guidance to contact administrator. + """ + exit_code = 1 + + def __init__(self, message: str, status_code: int = 500) -> None: + """Initialize server error. + + Args: + message: Error message + status_code: HTTP status code + """ + self.status_code = status_code + super().__init__( + message, + "Contact administrator if this error persists" + ) + + +class VersionMismatchError(UnityAuthCLIError): + """CLI version incompatible with API version. + + Exit code 4 indicates version compatibility issue. + """ + exit_code = 4 diff --git a/unityauth-cli/src/unityauth_cli/utils/validation.py b/unityauth-cli/src/unityauth_cli/utils/validation.py new file mode 100644 index 0000000..bdd11cf --- /dev/null +++ b/unityauth-cli/src/unityauth_cli/utils/validation.py @@ -0,0 +1,137 @@ +"""Input validation utilities for UnityAuth CLI. + +Provides validation functions for email, password, and other user inputs. +""" + +import re +from typing import List + + +def validate_email(email: str) -> bool: + """Validate email format. + + Args: + email: Email address to validate + + Returns: + True if email format is valid, False otherwise + """ + if not email: + return False + + # Simple email regex - matches most valid email formats + pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$' + return bool(re.match(pattern, email)) + + +def validate_password(password: str, min_length: int = 8) -> bool: + """Validate password meets minimum requirements. + + Args: + password: Password to validate + min_length: Minimum password length (default: 8) + + Returns: + True if password is valid, False otherwise + """ + return bool(password) and len(password) >= min_length + + +def validate_name(name: str, field_name: str = "Name", max_length: int = 100) -> List[str]: + """Validate name field (firstName, lastName). + + Args: + name: Name to validate + field_name: Name of the field for error messages + max_length: Maximum allowed length (default: 100) + + Returns: + List of error messages (empty if valid) + """ + errors = [] + + if not name or name.strip() == "": + errors.append(f"{field_name} must not be blank") + elif len(name) > max_length: + errors.append(f"{field_name} must be at most {max_length} characters") + + return errors + + +def validate_tenant_id(tenant_id: int) -> bool: + """Validate tenant ID is a positive integer. + + Args: + tenant_id: Tenant ID to validate + + Returns: + True if valid, False otherwise + """ + return isinstance(tenant_id, int) and tenant_id > 0 + + +def validate_role_ids(role_ids: str) -> List[int]: + """Parse and validate comma or pipe-separated role IDs. + + Args: + role_ids: String of role IDs separated by commas or pipes (e.g., "1,2,3" or "1|2|3") + + Returns: + List of integer role IDs + + Raises: + ValueError: If any role ID is not a valid positive integer + """ + if not role_ids: + return [] + + # Support both comma and pipe separators + separator = '|' if '|' in role_ids else ',' + parts = [part.strip() for part in role_ids.split(separator)] + + result = [] + for part in parts: + if not part: + continue + + try: + role_id = int(part) + if role_id <= 0: + raise ValueError(f"Role ID must be positive: {part}") + result.append(role_id) + except ValueError: + raise ValueError(f"Invalid role ID (must be integer): {part}") + + return result + + +def validate_status(status: str) -> bool: + """Validate user status value. + + Args: + status: Status value to validate + + Returns: + True if valid (ENABLED or DISABLED), False otherwise + """ + return status.upper() in ('ENABLED', 'DISABLED') + + +def validate_url(url: str, require_https: bool = True) -> bool: + """Validate URL format. + + Args: + url: URL to validate + require_https: If True, only HTTPS URLs are valid (default: True) + + Returns: + True if URL is valid, False otherwise + """ + if not url: + return False + + # Simple URL validation + if require_https: + return url.startswith('https://') + else: + return url.startswith('http://') or url.startswith('https://') diff --git a/unityauth-cli/tests/__init__.py b/unityauth-cli/tests/__init__.py new file mode 100644 index 0000000..ebddcac --- /dev/null +++ b/unityauth-cli/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for UnityAuth CLI.""" diff --git a/unityauth-cli/tests/conftest.py b/unityauth-cli/tests/conftest.py new file mode 100644 index 0000000..0155ac5 --- /dev/null +++ b/unityauth-cli/tests/conftest.py @@ -0,0 +1,66 @@ +"""Shared pytest fixtures for UnityAuth CLI tests.""" + +import pytest +from click.testing import CliRunner +from unittest.mock import MagicMock, patch + +from unityauth_cli.cli import CLIContext +from unityauth_cli.config import Configuration +from unityauth_cli.client import UnityAuthAPIClient + + +@pytest.fixture +def mock_config(): + """Create a mock Configuration object.""" + config = MagicMock(spec=Configuration) + config.get.return_value = None + config.config = { + 'api_url': 'https://auth.example.com', + 'timeout': 30, + 'default_format': 'table', + } + return config + + +@pytest.fixture +def cli_context(mock_config): + """Create a CLIContext with mock configuration.""" + ctx = CLIContext() + ctx.config = mock_config + ctx.api_url = 'https://auth.example.com' + ctx.output_format = 'table' + ctx.verbose = False + return ctx + + +@pytest.fixture +def mock_keyring(): + """Mock the keyring module.""" + with patch('unityauth_cli.auth.keyring') as mock: + yield mock + + +@pytest.fixture +def mock_requests_session(): + """Mock requests.Session for API client tests.""" + with patch('unityauth_cli.client.requests.Session') as mock: + session_instance = MagicMock() + mock.return_value = session_instance + yield session_instance + + +@pytest.fixture +def cli_runner(): + """Create a Click CLI test runner.""" + return CliRunner() + + +@pytest.fixture +def mock_api_client(): + """Create a mock API client.""" + client = MagicMock(spec=UnityAuthAPIClient) + client.get.return_value = None + client.post.return_value = None + client.patch.return_value = None + client.delete.return_value = None + return client diff --git a/unityauth-cli/tests/unit/__init__.py b/unityauth-cli/tests/unit/__init__.py new file mode 100644 index 0000000..4568dbc --- /dev/null +++ b/unityauth-cli/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for UnityAuth CLI.""" diff --git a/unityauth-cli/tests/unit/commands/__init__.py b/unityauth-cli/tests/unit/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unityauth-cli/tests/unit/commands/test_config.py b/unityauth-cli/tests/unit/commands/test_config.py new file mode 100644 index 0000000..6bfeadb --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_config.py @@ -0,0 +1,203 @@ +"""Tests for configuration management commands.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from unityauth_cli.commands.config import _flatten_config, _convert_value +from unityauth_cli.cli import cli, register_commands + + +# Register commands once for all tests +register_commands() + + +class TestShowCommand: + """Tests for the config show command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + def test_show_success(self, runner): + """Test successful config display.""" + mock_config = MagicMock() + mock_config.config = { + 'api_url': 'https://auth.example.com', + 'default_format': 'table' + } + mock_config.config_path = Path('/home/user/.config/unityauth-cli/config.yml') + + with patch('unityauth_cli.cli.Configuration', return_value=mock_config): + with patch('unityauth_cli.commands.config.console'): + with patch('unityauth_cli.commands.config.format_key_value_table', return_value='table'): + result = runner.invoke(cli, ['config', 'show']) + + assert result.exit_code == 0 + + def test_show_json_format(self, runner): + """Test config display in JSON format.""" + mock_config = MagicMock() + mock_config.config = {'api_url': 'https://auth.example.com'} + mock_config.config_path = Path('/home/user/.config/unityauth-cli/config.yml') + + with patch('unityauth_cli.cli.Configuration', return_value=mock_config): + with patch('unityauth_cli.commands.config.console'): + with patch('unityauth_cli.commands.config.format_json') as mock_format: + mock_format.return_value = '{"api_url": "https://auth.example.com"}' + result = runner.invoke(cli, ['--format', 'json', 'config', 'show']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + +class TestSetCommand: + """Tests for the config set command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + def test_set_string_value(self, runner): + """Test setting a string value.""" + mock_config = MagicMock() + mock_config.config = {} + mock_config.config_path = Path('/home/user/.config/unityauth-cli/config.yml') + + with patch('unityauth_cli.cli.Configuration', return_value=mock_config): + result = runner.invoke(cli, ['config', 'set', 'api_url', 'https://new.example.com']) + + assert result.exit_code == 0 + mock_config.set.assert_called_once_with('api_url', 'https://new.example.com') + mock_config.save.assert_called_once() + + def test_set_integer_value(self, runner): + """Test setting an integer value.""" + mock_config = MagicMock() + mock_config.config = {} + mock_config.config_path = Path('/home/user/.config/unityauth-cli/config.yml') + + with patch('unityauth_cli.cli.Configuration', return_value=mock_config): + result = runner.invoke(cli, ['config', 'set', 'timeout', '60']) + + assert result.exit_code == 0 + mock_config.set.assert_called_once_with('timeout', 60) + + def test_set_boolean_value(self, runner): + """Test setting a boolean value.""" + mock_config = MagicMock() + mock_config.config = {} + mock_config.config_path = Path('/home/user/.config/unityauth-cli/config.yml') + + with patch('unityauth_cli.cli.Configuration', return_value=mock_config): + result = runner.invoke(cli, ['config', 'set', 'verbose', 'true']) + + assert result.exit_code == 0 + mock_config.set.assert_called_once_with('verbose', True) + + +class TestEditCommand: + """Tests for the config edit command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + def test_edit_success(self, runner): + """Test successful config edit.""" + mock_config = MagicMock() + mock_config.config_path = MagicMock(spec=Path) + mock_config.config_path.exists.return_value = True + mock_config.config_path.__str__ = lambda self: '/home/user/.config/unityauth-cli/config.yml' + + with patch('unityauth_cli.cli.Configuration', return_value=mock_config): + with patch.dict('os.environ', {'EDITOR': 'nano'}): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0) + result = runner.invoke(cli, ['config', 'edit']) + + assert result.exit_code == 0 + mock_config.load.assert_called_once() + + +class TestFlattenConfig: + """Tests for the _flatten_config helper function.""" + + def test_flatten_simple_dict(self): + """Test flattening a simple dictionary.""" + config = {'api_url': 'https://example.com', 'timeout': 30} + result = _flatten_config(config) + assert result == {'api_url': 'https://example.com', 'timeout': 30} + + def test_flatten_nested_dict(self): + """Test flattening a nested dictionary.""" + config = { + 'api_url': 'https://example.com', + 'batch': { + 'max_size': 100, + 'continue_on_error': False + } + } + result = _flatten_config(config) + assert result == { + 'api_url': 'https://example.com', + 'batch.max_size': 100, + 'batch.continue_on_error': False + } + + def test_flatten_deeply_nested_dict(self): + """Test flattening a deeply nested dictionary.""" + config = { + 'level1': { + 'level2': { + 'level3': 'value' + } + } + } + result = _flatten_config(config) + assert result == {'level1.level2.level3': 'value'} + + def test_flatten_empty_dict(self): + """Test flattening an empty dictionary.""" + result = _flatten_config({}) + assert result == {} + + +class TestConvertValue: + """Tests for the _convert_value helper function.""" + + def test_convert_boolean_true_variations(self): + """Test conversion of various true values.""" + assert _convert_value('true') is True + assert _convert_value('True') is True + assert _convert_value('TRUE') is True + assert _convert_value('yes') is True + assert _convert_value('on') is True + + def test_convert_boolean_false_variations(self): + """Test conversion of various false values.""" + assert _convert_value('false') is False + assert _convert_value('False') is False + assert _convert_value('FALSE') is False + assert _convert_value('no') is False + assert _convert_value('off') is False + + def test_convert_integer(self): + """Test conversion of integer values.""" + assert _convert_value('42') == 42 + assert _convert_value('-10') == -10 + + def test_convert_float(self): + """Test conversion of float values.""" + assert _convert_value('3.14') == 3.14 + assert _convert_value('-2.5') == -2.5 + + def test_convert_string(self): + """Test conversion of string values.""" + assert _convert_value('hello') == 'hello' + assert _convert_value('https://example.com') == 'https://example.com' + assert _convert_value('') == '' diff --git a/unityauth-cli/tests/unit/commands/test_init.py b/unityauth-cli/tests/unit/commands/test_init.py new file mode 100644 index 0000000..a22ce81 --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_init.py @@ -0,0 +1,227 @@ +"""Tests for init command (setup wizard).""" + +import pytest +from unittest.mock import MagicMock, patch, Mock +from click.testing import CliRunner + +from unityauth_cli.cli import cli, register_commands + + +# Register commands once for all tests +register_commands() + + +class TestInitCommand: + """Tests for the init command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + def test_init_help(self, runner): + """Test init --help shows correct information.""" + result = runner.invoke(cli, ['init', '--help']) + + assert result.exit_code == 0 + assert 'Initialize UnityAuth CLI' in result.output + assert '--api-url' in result.output + assert '--skip-login' in result.output + assert '--allow-http' in result.output + + def test_init_requires_api_url_non_interactive(self, runner): + """Test init fails without API URL in non-interactive mode.""" + result = runner.invoke(cli, ['init']) + + # Should fail because we're in non-interactive mode (no TTY) + assert result.exit_code != 0 + assert 'API URL required' in result.output or 'Aborted' in result.output + + def test_init_invalid_url_format(self, runner): + """Test init rejects invalid URL format.""" + result = runner.invoke(cli, [ + 'init', + '--api-url', 'not-a-url', + '--skip-login' + ]) + + assert result.exit_code != 0 + assert 'Invalid URL' in result.output or 'https://' in result.output + + def test_init_rejects_http_by_default(self, runner): + """Test init rejects HTTP URLs without --allow-http flag.""" + result = runner.invoke(cli, [ + 'init', + '--api-url', 'http://auth.example.com', + '--skip-login' + ]) + + assert result.exit_code != 0 + assert 'HTTP' in result.output or 'insecure' in result.output.lower() + + def test_init_allows_http_with_flag(self, runner): + """Test init accepts HTTP URLs with --allow-http flag.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'http://localhost:8081', + '--skip-login', + '--allow-http' + ]) + + assert result.exit_code == 0 + assert 'Connection successful' in result.output + + def test_init_success_with_valid_url(self, runner): + """Test successful init with valid HTTPS URL.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ]) + + assert result.exit_code == 0 + assert 'Welcome to UnityAuth CLI' in result.output + assert 'Connection successful' in result.output + assert 'Configuration saved' in result.output + assert 'Setup complete' in result.output + + def test_init_shows_next_steps(self, runner): + """Test init shows helpful next steps after completion.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ]) + + assert result.exit_code == 0 + assert 'Next steps' in result.output + assert 'tenant list' in result.output + assert 'user list' in result.output + + def test_init_connection_failure_prompts_continue(self, runner): + """Test init handles connection failure gracefully.""" + import requests + + with patch('unityauth_cli.commands.init.requests.get', side_effect=requests.ConnectionError()): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ], input='n\n') # Answer 'no' to continue prompt + + assert 'Could not connect' in result.output + assert 'cancelled' in result.output.lower() or result.exit_code == 0 + + def test_init_connection_failure_can_continue(self, runner): + """Test init can continue after connection failure if user confirms.""" + import requests + + with patch('unityauth_cli.commands.init.requests.get', side_effect=requests.ConnectionError()): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ], input='y\n') # Answer 'yes' to continue prompt + + assert result.exit_code == 0 + assert 'Configuration saved' in result.output + + def test_init_server_error_prompts_continue(self, runner): + """Test init handles server errors gracefully.""" + mock_response = Mock() + mock_response.ok = False + mock_response.status_code = 500 + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ], input='n\n') # Answer 'no' to continue prompt + + assert 'status 500' in result.output + assert result.exit_code == 0 + + def test_init_skip_login_flag(self, runner): + """Test --skip-login flag skips the login prompt.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ]) + + assert result.exit_code == 0 + # Should not prompt for login + assert 'Email:' not in result.output + assert 'Password:' not in result.output + + def test_init_saves_api_url_to_config(self, runner): + """Test init saves API URL to configuration.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + + saved_values = {} + + def mock_set(key, value): + saved_values[key] = value + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + with patch('unityauth_cli.config.Configuration.set', side_effect=mock_set): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com', + '--skip-login' + ]) + + assert result.exit_code == 0 + assert saved_values.get('api_url') == 'https://auth.example.com' + + def test_init_strips_trailing_slash(self, runner): + """Test init strips trailing slash from API URL.""" + mock_response = Mock() + mock_response.ok = True + mock_response.status_code = 200 + + saved_values = {} + + def mock_set(key, value): + saved_values[key] = value + + with patch('unityauth_cli.commands.init.requests.get', return_value=mock_response): + with patch('unityauth_cli.config.Configuration.set', side_effect=mock_set): + with patch('unityauth_cli.config.Configuration.save'): + result = runner.invoke(cli, [ + 'init', + '--api-url', 'https://auth.example.com/', + '--skip-login' + ]) + + assert result.exit_code == 0 + assert saved_values.get('api_url') == 'https://auth.example.com' diff --git a/unityauth-cli/tests/unit/commands/test_login.py b/unityauth-cli/tests/unit/commands/test_login.py new file mode 100644 index 0000000..da825b6 --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_login.py @@ -0,0 +1,132 @@ +"""Tests for login/logout commands.""" + +import pytest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from unityauth_cli.cli import cli, register_commands +from unityauth_cli.utils.errors import AuthenticationError + + +# Register commands once for all tests +register_commands() + + +class TestLoginCommand: + """Tests for the login command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + def test_login_missing_email_non_interactive(self, runner): + """Test login without email in non-interactive mode.""" + # In non-interactive mode (no TTY), missing email should fail + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'login', + '--password', 'password123' + ]) + + # Should fail because we're in non-interactive mode and email is required + assert result.exit_code != 0 + + +class TestLogoutCommand: + """Tests for the logout command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + def test_logout_success(self, runner): + """Test successful logout.""" + with patch('unityauth_cli.commands.login.auth.has_token', return_value=True): + with patch('unityauth_cli.commands.login.auth.delete_token', return_value=True): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'logout' + ]) + + assert result.exit_code == 0 + assert 'logout' in result.output.lower() or 'successful' in result.output.lower() + + def test_logout_no_active_session(self, runner): + """Test logout when no session exists.""" + with patch('unityauth_cli.commands.login.auth.has_token', return_value=False): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'logout' + ]) + + # Should still succeed but with warning message + assert result.exit_code == 0 + assert 'no active session' in result.output.lower() + + +class TestTokenInfoCommand: + """Tests for the token-info command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_token_info_success(self, runner, mock_client): + """Test successful token info retrieval.""" + mock_client.get.return_value = { + 'email': 'user@example.com', + 'name': 'Test User', + 'exp': 1704067200 + } + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.login.console'): + with patch('unityauth_cli.commands.login.format_key_value_table', return_value='table'): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'token-info' + ]) + + assert result.exit_code == 0 + mock_client.get.assert_called_once_with('/api/token_info') + + def test_token_info_json_format(self, runner, mock_client): + """Test token info in JSON format.""" + mock_client.get.return_value = { + 'email': 'user@example.com', + 'name': 'Test User' + } + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.login.console'): + with patch('unityauth_cli.commands.login.format_json') as mock_format: + mock_format.return_value = '{"email": "user@example.com"}' + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + '--format', 'json', + 'token-info' + ]) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_token_info_not_authenticated(self, runner): + """Test token info when not authenticated.""" + with patch('unityauth_cli.auth.get_token', return_value=None): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'token-info' + ]) + + # Should fail with authentication error + assert result.exit_code != 0 diff --git a/unityauth-cli/tests/unit/commands/test_permissions.py b/unityauth-cli/tests/unit/commands/test_permissions.py new file mode 100644 index 0000000..dfc7746 --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_permissions.py @@ -0,0 +1,211 @@ +"""Tests for permission discovery commands.""" + +import pytest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from unityauth_cli.cli import cli, register_commands +from unityauth_cli.utils.errors import AuthorizationError + + +# Register commands once for all tests +register_commands() + + +class TestListPermissionsCommand: + """Tests for the permissions list command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_list_permissions_success(self, runner, mock_client): + """Test successful permission listing.""" + mock_client.post.return_value = { + 'permissions': ['AUTH_SERVICE_VIEW-SYSTEM', 'AUTH_SERVICE_EDIT-TENANT'] + } + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.permissions.console'): + with patch('unityauth_cli.commands.permissions.format_table') as mock_format: + mock_format.return_value = 'formatted table' + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 0 + mock_client.post.assert_called_once_with('/api/principal/permissions', data={ + 'tenantId': 1, + 'serviceId': 1 + }) + mock_format.assert_called_once() + + def test_list_permissions_empty_response(self, runner, mock_client): + """Test handling of empty permissions.""" + mock_client.post.return_value = {'permissions': []} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 0 + assert 'No permissions found' in result.output + + def test_list_permissions_null_response(self, runner, mock_client): + """Test handling of null response.""" + mock_client.post.return_value = None + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 0 + assert 'No permissions found' in result.output + + def test_list_permissions_error_response(self, runner, mock_client): + """Test handling of error response from API.""" + mock_client.post.return_value = {'errorMessage': 'No tenant found.'} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 1 + + def test_list_permissions_json_format(self, runner, mock_client): + """Test JSON output format.""" + mock_client.post.return_value = {'permissions': ['PERM1', 'PERM2']} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.permissions.console'): + with patch('unityauth_cli.commands.permissions.format_json') as mock_format: + mock_format.return_value = '{"permissions": ["PERM1"]}' + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + '--format', 'json', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_permissions_csv_format(self, runner, mock_client): + """Test CSV output format.""" + mock_client.post.return_value = {'permissions': ['PERM1', 'PERM2']} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.permissions.console'): + with patch('unityauth_cli.commands.permissions.format_csv') as mock_format: + mock_format.return_value = 'permission\nPERM1\nPERM2' + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + '--format', 'csv', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_permissions_invalid_tenant_id(self, runner, mock_client): + """Test that invalid tenant ID raises error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '0', + '--service-id', '1' + ]) + + assert result.exit_code != 0 + + def test_list_permissions_invalid_service_id(self, runner, mock_client): + """Test that invalid service ID raises error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '0' + ]) + + assert result.exit_code != 0 + + def test_list_permissions_negative_ids(self, runner, mock_client): + """Test that negative IDs raise error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '-1', + '--service-id', '-1' + ]) + + assert result.exit_code != 0 + + def test_list_permissions_authorization_error(self, runner, mock_client): + """Test authorization error handling.""" + mock_client.post.side_effect = AuthorizationError("Not authorized") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 3 + + def test_list_permissions_verbose_output(self, runner, mock_client): + """Test verbose output.""" + mock_client.post.return_value = {'permissions': ['PERM1', 'PERM2']} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.permissions.console'): + with patch('unityauth_cli.commands.permissions.format_table', return_value='table'): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + '--verbose', + 'permissions', 'list', + '--tenant-id', '1', + '--service-id', '1' + ]) + + assert result.exit_code == 0 + assert 'Total permissions' in result.output diff --git a/unityauth-cli/tests/unit/commands/test_roles.py b/unityauth-cli/tests/unit/commands/test_roles.py new file mode 100644 index 0000000..e7df2fb --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_roles.py @@ -0,0 +1,186 @@ +"""Tests for role discovery commands.""" + +import pytest +from unittest.mock import MagicMock, patch +import click +from click.testing import CliRunner + +from unityauth_cli.commands.roles import list_roles +from unityauth_cli.cli import cli, register_commands +from unityauth_cli.utils.errors import AuthorizationError + + +# Register commands once for all tests +register_commands() + + +class TestListRolesCommand: + """Tests for the role list command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_list_roles_success(self, runner, mock_client): + """Test successful role listing.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Unity Administrator', 'description': 'System admin role'}, + {'id': 2, 'name': 'Tenant Administrator', 'description': 'Tenant admin role'}, + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_table') as mock_format: + mock_format.return_value = 'formatted table' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 0 + mock_client.get.assert_called_once_with('/api/roles') + mock_format.assert_called_once() + + def test_list_roles_empty_response(self, runner, mock_client): + """Test handling of empty response.""" + mock_client.get.return_value = None + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 0 + assert 'No roles found' in result.output + + def test_list_roles_empty_list(self, runner, mock_client): + """Test handling of empty list.""" + mock_client.get.return_value = [] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 0 + assert 'No roles found' in result.output + + def test_list_roles_json_format(self, runner, mock_client): + """Test JSON output format.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Admin', 'description': 'Admin role'} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_json') as mock_format: + mock_format.return_value = '[{"id": 1}]' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--format', 'json', 'role', 'list']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_roles_csv_format(self, runner, mock_client): + """Test CSV output format.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Admin', 'description': 'Admin role'} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_csv') as mock_format: + mock_format.return_value = 'id,name,description\n1,Admin,Admin role' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--format', 'csv', 'role', 'list']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_roles_handles_null_description(self, runner, mock_client): + """Test that null description is handled correctly.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Admin', 'description': None} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_table') as mock_format: + mock_format.return_value = 'table' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 0 + # Check that N/A is used for null description + rows = mock_format.call_args[0][0] + assert rows[0][2] == 'N/A' + + def test_list_roles_handles_empty_description(self, runner, mock_client): + """Test that empty description is handled correctly.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Admin', 'description': ''} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_table') as mock_format: + mock_format.return_value = 'table' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 0 + # Check that N/A is used for empty description + rows = mock_format.call_args[0][0] + assert rows[0][2] == 'N/A' + + def test_list_roles_authorization_error(self, runner, mock_client): + """Test authorization error handling.""" + mock_client.get.side_effect = AuthorizationError("Permission denied") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 3 + + def test_list_roles_verbose_output(self, runner, mock_client): + """Test verbose output.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Admin', 'description': 'Admin role'}, + {'id': 2, 'name': 'User', 'description': 'User role'}, + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_table', return_value='table'): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--verbose', 'role', 'list']) + + assert result.exit_code == 0 + assert 'Total roles' in result.output + + def test_list_roles_single_role_as_dict(self, runner, mock_client): + """Test handling when response is a single dict instead of list.""" + mock_client.get.return_value = {'id': 1, 'name': 'Admin', 'description': 'Role'} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.roles.console'): + with patch('unityauth_cli.commands.roles.format_table') as mock_format: + mock_format.return_value = 'table' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + assert result.exit_code == 0 + # Should wrap single dict in list + mock_format.assert_called_once() + + def test_list_roles_not_authenticated(self, runner): + """Test that unauthenticated request fails with AuthenticationError.""" + with patch('unityauth_cli.auth.get_token', return_value=None): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'role', 'list']) + + # AuthenticationError is raised but Click wraps it, resulting in exit code 1 + assert result.exit_code != 0 + assert result.exception is not None diff --git a/unityauth-cli/tests/unit/commands/test_tenants.py b/unityauth-cli/tests/unit/commands/test_tenants.py new file mode 100644 index 0000000..3e55c71 --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_tenants.py @@ -0,0 +1,234 @@ +"""Tests for tenant discovery commands.""" + +import pytest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from unityauth_cli.cli import cli, register_commands +from unityauth_cli.utils.errors import AuthorizationError + + +# Register commands once for all tests +register_commands() + + +class TestListTenantsCommand: + """Tests for the tenant list command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_list_tenants_success(self, runner, mock_client): + """Test successful tenant listing.""" + mock_client.get.return_value = [ + {'id': 1, 'name': 'Tenant One'}, + {'id': 2, 'name': 'Tenant Two'}, + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_table') as mock_format: + mock_format.return_value = 'formatted table' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'list']) + + assert result.exit_code == 0 + mock_client.get.assert_called_once_with('/api/tenants') + mock_format.assert_called_once() + + def test_list_tenants_empty_response(self, runner, mock_client): + """Test handling of empty response.""" + mock_client.get.return_value = None + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'list']) + + assert result.exit_code == 0 + assert 'No tenants found' in result.output + + def test_list_tenants_empty_list(self, runner, mock_client): + """Test handling of empty list.""" + mock_client.get.return_value = [] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'list']) + + assert result.exit_code == 0 + assert 'No tenants found' in result.output + + def test_list_tenants_json_format(self, runner, mock_client): + """Test JSON output format.""" + mock_client.get.return_value = [{'id': 1, 'name': 'Tenant'}] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_json') as mock_format: + mock_format.return_value = '[{"id": 1}]' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--format', 'json', 'tenant', 'list']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_tenants_csv_format(self, runner, mock_client): + """Test CSV output format.""" + mock_client.get.return_value = [{'id': 1, 'name': 'Tenant'}] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_csv') as mock_format: + mock_format.return_value = 'id,name\n1,Tenant' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--format', 'csv', 'tenant', 'list']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_tenants_authorization_error(self, runner, mock_client): + """Test authorization error handling.""" + mock_client.get.side_effect = AuthorizationError("Permission denied") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'list']) + + assert result.exit_code == 3 + + def test_list_tenants_verbose_output(self, runner, mock_client): + """Test verbose output.""" + mock_client.get.return_value = [{'id': 1, 'name': 'Tenant'}] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_table', return_value='table'): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--verbose', 'tenant', 'list']) + + assert result.exit_code == 0 + assert 'Total tenants' in result.output + + +class TestTenantUsersCommand: + """Tests for the tenant users command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_tenant_users_success(self, runner, mock_client): + """Test successful user listing for tenant.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user1@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]}, + {'id': 2, 'email': 'user2@example.com', 'firstName': 'User', 'lastName': 'Two', 'roles': [2, 3]}, + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_table') as mock_format: + mock_format.return_value = 'formatted table' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'users', '1']) + + assert result.exit_code == 0 + mock_client.get.assert_called_once_with('/api/tenants/1/users') + mock_format.assert_called_once() + + def test_tenant_users_empty_response(self, runner, mock_client): + """Test handling of empty response.""" + mock_client.get.return_value = None + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'users', '1']) + + assert result.exit_code == 0 + assert 'No users found' in result.output + + def test_tenant_users_invalid_tenant_id(self, runner, mock_client): + """Test that invalid tenant ID raises error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'users', '0']) + + assert result.exit_code != 0 + + def test_tenant_users_negative_tenant_id(self, runner, mock_client): + """Test that negative tenant ID raises error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'users', '-1']) + + # Click may reject negative integers, checking for non-zero exit + assert result.exit_code != 0 + + def test_tenant_users_json_format(self, runner, mock_client): + """Test JSON output format.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_json') as mock_format: + mock_format.return_value = '[{"id": 1}]' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--format', 'json', 'tenant', 'users', '1']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_tenant_users_csv_format(self, runner, mock_client): + """Test CSV output format.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_csv') as mock_format: + mock_format.return_value = 'csv,data' + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--format', 'csv', 'tenant', 'users', '1']) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_tenant_users_authorization_error(self, runner, mock_client): + """Test authorization error handling.""" + mock_client.get.side_effect = AuthorizationError("Permission denied") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', 'tenant', 'users', '1']) + + assert result.exit_code == 3 + + def test_tenant_users_verbose_output(self, runner, mock_client): + """Test verbose output.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.tenants.console'): + with patch('unityauth_cli.commands.tenants.format_table', return_value='table'): + result = runner.invoke(cli, ['--api-url', 'https://auth.example.com', '--verbose', 'tenant', 'users', '1']) + + assert result.exit_code == 0 + assert 'Total users' in result.output diff --git a/unityauth-cli/tests/unit/commands/test_users.py b/unityauth-cli/tests/unit/commands/test_users.py new file mode 100644 index 0000000..dc6de58 --- /dev/null +++ b/unityauth-cli/tests/unit/commands/test_users.py @@ -0,0 +1,324 @@ +"""Tests for user management commands.""" + +import pytest +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from unityauth_cli.cli import cli, register_commands +from unityauth_cli.utils.errors import AuthorizationError + + +# Register commands once for all tests +register_commands() + + +class TestCreateCommand: + """Tests for the user create command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_create_user_success(self, runner, mock_client): + """Test successful user creation.""" + mock_client.post.return_value = { + 'id': 1, + 'email': 'test@example.com', + 'firstName': 'Test', + 'lastName': 'User', + 'roles': [2, 3] + } + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'create', + '--email', 'test@example.com', + '--first-name', 'Test', + '--last-name', 'User', + '--password', 'SecureP@ss123', + '--tenant-id', '1', + '--role-ids', '2,3' + ]) + + assert result.exit_code == 0 + mock_client.post.assert_called_once() + assert 'created successfully' in result.output + + def test_create_user_invalid_email(self, runner, mock_client): + """Test that invalid email format raises error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'create', + '--email', 'invalid-email', + '--first-name', 'Test', + '--last-name', 'User', + '--password', 'SecureP@ss123', + '--tenant-id', '1', + '--role-ids', '2' + ]) + + assert result.exit_code != 0 + + def test_create_user_password_too_short(self, runner, mock_client): + """Test that short password raises error.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'create', + '--email', 'test@example.com', + '--first-name', 'Test', + '--last-name', 'User', + '--password', 'short', + '--tenant-id', '1', + '--role-ids', '2' + ]) + + assert result.exit_code != 0 + + def test_create_user_authorization_error(self, runner, mock_client): + """Test that authorization error is handled correctly.""" + mock_client.post.side_effect = AuthorizationError("Permission denied") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'create', + '--email', 'test@example.com', + '--first-name', 'Test', + '--last-name', 'User', + '--password', 'SecureP@ss123', + '--tenant-id', '1', + '--role-ids', '2' + ]) + + assert result.exit_code == 3 + + +class TestUpdateCommand: + """Tests for the user update command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_update_user_roles_success(self, runner, mock_client): + """Test successful role update.""" + mock_client.patch.return_value = { + 'id': 5, + 'email': 'test@example.com', + 'roles': [1, 2] + } + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'update', + '5', + '--tenant-id', '1', + '--role-ids', '1,2' + ]) + + assert result.exit_code == 0 + mock_client.patch.assert_called_once() + assert 'updated successfully' in result.output + + def test_update_user_authorization_error(self, runner, mock_client): + """Test authorization error handling.""" + mock_client.patch.side_effect = AuthorizationError("Permission denied") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'update', + '5', + '--tenant-id', '1', + '--role-ids', '1,2' + ]) + + assert result.exit_code == 3 + + +class TestUpdateProfileCommand: + """Tests for the user update-profile command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_update_profile_first_name(self, runner, mock_client): + """Test updating first name.""" + mock_client.patch.return_value = {'id': 5, 'firstName': 'NewName'} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'update-profile', + '5', + '--first-name', 'NewName' + ]) + + assert result.exit_code == 0 + mock_client.patch.assert_called_once() + + def test_update_profile_all_fields(self, runner, mock_client): + """Test updating all fields at once.""" + mock_client.patch.return_value = {'id': 5} + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'update-profile', + '5', + '--first-name', 'John', + '--last-name', 'Smith', + '--password', 'NewSecureP@ss' + ]) + + assert result.exit_code == 0 + mock_client.patch.assert_called_once() + + def test_update_profile_no_fields_provided(self, runner, mock_client): + """Test that error is raised when no fields provided.""" + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'update-profile', + '5' + ]) + + assert result.exit_code != 0 + + +class TestListUsersCommand: + """Tests for the user list command.""" + + @pytest.fixture + def runner(self): + """Create a CLI runner.""" + return CliRunner() + + @pytest.fixture + def mock_client(self): + """Create a mock API client.""" + return MagicMock() + + def test_list_users_success(self, runner, mock_client): + """Test successful user listing.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user1@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]}, + {'id': 2, 'email': 'user2@example.com', 'firstName': 'User', 'lastName': 'Two', 'roles': [2, 3]}, + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.users.console'): + with patch('unityauth_cli.commands.users.format_table') as mock_format: + mock_format.return_value = 'formatted table' + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'list', + '--tenant-id', '1' + ]) + + assert result.exit_code == 0 + mock_client.get.assert_called_once_with('/api/tenants/1/users') + mock_format.assert_called_once() + + def test_list_users_empty_response(self, runner, mock_client): + """Test handling of empty response.""" + mock_client.get.return_value = None + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'list', + '--tenant-id', '1' + ]) + + assert result.exit_code == 0 + assert 'No users found' in result.output + + def test_list_users_json_format(self, runner, mock_client): + """Test JSON output format.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.users.console'): + with patch('unityauth_cli.commands.users.format_json') as mock_format: + mock_format.return_value = '{"users": []}' + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + '--format', 'json', + 'user', 'list', + '--tenant-id', '1' + ]) + + assert result.exit_code == 0 + mock_format.assert_called_once() + + def test_list_users_authorization_error(self, runner, mock_client): + """Test authorization error handling.""" + mock_client.get.side_effect = AuthorizationError("Permission denied") + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + 'user', 'list', + '--tenant-id', '1' + ]) + + assert result.exit_code == 3 + + def test_list_users_verbose_output(self, runner, mock_client): + """Test verbose output.""" + mock_client.get.return_value = [ + {'id': 1, 'email': 'user@example.com', 'firstName': 'User', 'lastName': 'One', 'roles': [1]} + ] + + with patch('unityauth_cli.auth.get_token', return_value='fake-token'): + with patch('unityauth_cli.client.UnityAuthAPIClient', return_value=mock_client): + with patch('unityauth_cli.commands.users.console'): + with patch('unityauth_cli.commands.users.format_table', return_value='table'): + result = runner.invoke(cli, [ + '--api-url', 'https://auth.example.com', + '--verbose', + 'user', 'list', + '--tenant-id', '1' + ]) + + assert result.exit_code == 0 + assert 'Total users' in result.output diff --git a/unityauth-cli/tests/unit/test_auth.py b/unityauth-cli/tests/unit/test_auth.py new file mode 100644 index 0000000..bb4be51 --- /dev/null +++ b/unityauth-cli/tests/unit/test_auth.py @@ -0,0 +1,129 @@ +"""Unit tests for the auth module (token storage/retrieval).""" + +import pytest +from unittest.mock import MagicMock, patch + +from unityauth_cli import auth +from unityauth_cli.utils.errors import AuthenticationError + + +class TestStoreToken: + """Tests for auth.store_token().""" + + def test_store_token_success(self, mock_keyring): + """Should store token in keyring successfully.""" + api_url = "https://auth.example.com" + token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" + + auth.store_token(api_url, token) + + mock_keyring.set_password.assert_called_once_with( + auth.SERVICE_NAME, api_url, token + ) + + def test_store_token_keyring_failure(self, mock_keyring): + """Should raise AuthenticationError when keyring fails.""" + mock_keyring.set_password.side_effect = Exception("Keyring unavailable") + + with pytest.raises(AuthenticationError) as exc_info: + auth.store_token("https://auth.example.com", "token") + + assert "Failed to store authentication token" in exc_info.value.message + assert "keyring service" in exc_info.value.details.lower() + + +class TestGetToken: + """Tests for auth.get_token().""" + + def test_get_token_success(self, mock_keyring): + """Should retrieve token from keyring.""" + api_url = "https://auth.example.com" + expected_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" + mock_keyring.get_password.return_value = expected_token + + token = auth.get_token(api_url) + + assert token == expected_token + mock_keyring.get_password.assert_called_once_with(auth.SERVICE_NAME, api_url) + + def test_get_token_not_found(self, mock_keyring): + """Should return None when no token exists.""" + mock_keyring.get_password.return_value = None + + token = auth.get_token("https://auth.example.com") + + assert token is None + + def test_get_token_keyring_failure(self, mock_keyring): + """Should raise AuthenticationError when keyring fails.""" + mock_keyring.get_password.side_effect = Exception("Keyring unavailable") + + with pytest.raises(AuthenticationError) as exc_info: + auth.get_token("https://auth.example.com") + + assert "Failed to retrieve authentication token" in exc_info.value.message + + +class TestDeleteToken: + """Tests for auth.delete_token().""" + + def test_delete_token_success(self, mock_keyring): + """Should delete token from keyring.""" + api_url = "https://auth.example.com" + + auth.delete_token(api_url) + + mock_keyring.delete_password.assert_called_once_with(auth.SERVICE_NAME, api_url) + + def test_delete_token_not_found(self): + """Should not raise error when token doesn't exist.""" + with patch('unityauth_cli.auth.keyring') as mock_keyring: + # Create a mock PasswordDeleteError exception class + PasswordDeleteError = type('PasswordDeleteError', (Exception,), {}) + mock_keyring.errors.PasswordDeleteError = PasswordDeleteError + mock_keyring.delete_password.side_effect = PasswordDeleteError() + + # Should not raise + auth.delete_token("https://auth.example.com") + + def test_delete_token_keyring_failure(self): + """Should raise AuthenticationError on unexpected keyring failure.""" + with patch('unityauth_cli.auth.keyring') as mock_keyring: + # Set up PasswordDeleteError as a real exception class + mock_keyring.errors.PasswordDeleteError = type( + 'PasswordDeleteError', (Exception,), {} + ) + mock_keyring.delete_password.side_effect = RuntimeError("Unexpected error") + + with pytest.raises(AuthenticationError) as exc_info: + auth.delete_token("https://auth.example.com") + + assert "Failed to delete authentication token" in exc_info.value.message + + +class TestHasToken: + """Tests for auth.has_token().""" + + def test_has_token_true(self, mock_keyring): + """Should return True when token exists.""" + mock_keyring.get_password.return_value = "some-token" + + result = auth.has_token("https://auth.example.com") + + assert result is True + + def test_has_token_false(self, mock_keyring): + """Should return False when no token exists.""" + mock_keyring.get_password.return_value = None + + result = auth.has_token("https://auth.example.com") + + assert result is False + + +class TestServiceName: + """Tests for service name constant.""" + + def test_service_name_is_correct(self): + """Service name should be 'unityauth-cli'.""" + assert auth.SERVICE_NAME == "unityauth-cli" diff --git a/unityauth-cli/tests/unit/test_client.py b/unityauth-cli/tests/unit/test_client.py new file mode 100644 index 0000000..68a361e --- /dev/null +++ b/unityauth-cli/tests/unit/test_client.py @@ -0,0 +1,468 @@ +"""Unit tests for the UnityAuth API client.""" + +import pytest +from unittest.mock import MagicMock, patch, PropertyMock +import requests + +from unityauth_cli.client import UnityAuthAPIClient +from unityauth_cli.utils.errors import ( + AuthenticationError, + AuthorizationError, + ConfigurationError, + NetworkError, + NotFoundError, + RateLimitError, + ServerError, + ValidationError, + VersionMismatchError, +) + + +class TestClientInitialization: + """Tests for UnityAuthAPIClient initialization.""" + + def test_init_sets_base_url(self): + """Should set base URL correctly.""" + client = UnityAuthAPIClient("https://auth.example.com") + assert client.base_url == "https://auth.example.com" + + def test_init_strips_trailing_slash(self): + """Should strip trailing slash from base URL.""" + client = UnityAuthAPIClient("https://auth.example.com/") + assert client.base_url == "https://auth.example.com" + + def test_init_sets_default_timeout(self): + """Should use default timeout of 30 seconds.""" + client = UnityAuthAPIClient("https://auth.example.com") + assert client.timeout == 30 + + def test_init_accepts_custom_timeout(self): + """Should accept custom timeout.""" + client = UnityAuthAPIClient("https://auth.example.com", timeout=60) + assert client.timeout == 60 + + def test_init_sets_token(self): + """Should set Authorization header when token provided.""" + client = UnityAuthAPIClient("https://auth.example.com", token="test-token") + assert "Authorization" in client.session.headers + assert client.session.headers["Authorization"] == "Bearer test-token" + + def test_init_without_token(self): + """Should not set Authorization header when no token provided.""" + client = UnityAuthAPIClient("https://auth.example.com") + assert "Authorization" not in client.session.headers + + def test_init_sets_user_agent(self): + """Should set User-Agent header.""" + client = UnityAuthAPIClient("https://auth.example.com") + assert "User-Agent" in client.session.headers + assert "unityauth-cli" in client.session.headers["User-Agent"] + + def test_init_sets_content_type(self): + """Should set Content-Type to application/json.""" + client = UnityAuthAPIClient("https://auth.example.com") + assert client.session.headers["Content-Type"] == "application/json" + + +class TestTokenManagement: + """Tests for token management methods.""" + + def test_set_token(self): + """Should set Authorization header.""" + client = UnityAuthAPIClient("https://auth.example.com") + client.set_token("new-token") + assert client.session.headers["Authorization"] == "Bearer new-token" + + def test_clear_token(self): + """Should remove Authorization header.""" + client = UnityAuthAPIClient("https://auth.example.com", token="test-token") + client.clear_token() + assert "Authorization" not in client.session.headers + + def test_clear_token_when_not_set(self): + """Should not raise error when clearing non-existent token.""" + client = UnityAuthAPIClient("https://auth.example.com") + client.clear_token() # Should not raise + assert "Authorization" not in client.session.headers + + +class TestHTTPMethods: + """Tests for HTTP method wrappers.""" + + @pytest.fixture + def client(self): + """Create a client with mocked session.""" + with patch('unityauth_cli.client.requests.Session') as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + mock_session.headers = {} + + client = UnityAuthAPIClient("https://auth.example.com") + client.session = mock_session + yield client + + def test_get_request(self, client): + """Should make GET request to correct URL.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'{"data": "test"}' + mock_response.json.return_value = {"data": "test"} + mock_response.headers = {} + client.session.request.return_value = mock_response + + result = client.get("/api/users") + + client.session.request.assert_called_once() + call_args = client.session.request.call_args + assert call_args[0] == ("GET", "https://auth.example.com/api/users") + assert result == {"data": "test"} + + def test_post_request_with_data(self, client): + """Should make POST request with JSON data.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'{"id": 1}' + mock_response.json.return_value = {"id": 1} + mock_response.headers = {} + client.session.request.return_value = mock_response + + result = client.post("/api/users", data={"email": "test@example.com"}) + + client.session.request.assert_called_once() + call_args = client.session.request.call_args + assert call_args[0] == ("POST", "https://auth.example.com/api/users") + assert call_args[1]["json"] == {"email": "test@example.com"} + + def test_put_request(self, client): + """Should make PUT request.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'{}' + mock_response.json.return_value = {} + mock_response.headers = {} + client.session.request.return_value = mock_response + + client.put("/api/users/1", data={"name": "Updated"}) + + call_args = client.session.request.call_args + assert call_args[0] == ("PUT", "https://auth.example.com/api/users/1") + + def test_patch_request(self, client): + """Should make PATCH request.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'{}' + mock_response.json.return_value = {} + mock_response.headers = {} + client.session.request.return_value = mock_response + + client.patch("/api/users/1", data={"roles": [1, 2]}) + + call_args = client.session.request.call_args + assert call_args[0] == ("PATCH", "https://auth.example.com/api/users/1") + + def test_delete_request(self, client): + """Should make DELETE request.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'' + mock_response.headers = {} + client.session.request.return_value = mock_response + + result = client.delete("/api/users/1") + + call_args = client.session.request.call_args + assert call_args[0] == ("DELETE", "https://auth.example.com/api/users/1") + assert result is None # Empty response + + def test_empty_response_returns_none(self, client): + """Should return None for empty response body.""" + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'' + mock_response.headers = {} + client.session.request.return_value = mock_response + + result = client.get("/api/logout") + + assert result is None + + +class TestErrorHandling: + """Tests for HTTP error handling.""" + + @pytest.fixture + def client(self): + """Create a client with mocked session.""" + with patch('unityauth_cli.client.requests.Session') as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + mock_session.headers = {} + + client = UnityAuthAPIClient("https://auth.example.com") + client.session = mock_session + yield client + + def _make_error_response(self, status_code, message=None, headers=None): + """Helper to create mock error responses.""" + response = MagicMock() + response.ok = False + response.status_code = status_code + response.headers = headers or {} + + if message: + response.json.return_value = {"message": message} + response.text = message + else: + response.json.side_effect = ValueError("No JSON") + response.text = f"HTTP {status_code}" + + return response + + def test_401_raises_authentication_error(self, client): + """Should raise AuthenticationError for 401 response.""" + client.session.request.return_value = self._make_error_response( + 401, "Invalid credentials" + ) + + with pytest.raises(AuthenticationError) as exc_info: + client.get("/api/users") + + assert "Authentication failed" in exc_info.value.message + assert "unityauth login" in exc_info.value.details + + def test_403_raises_authorization_error(self, client): + """Should raise AuthorizationError for 403 response.""" + client.session.request.return_value = self._make_error_response( + 403, "Insufficient permissions" + ) + + with pytest.raises(AuthorizationError) as exc_info: + client.get("/api/admin") + + assert "Permission denied" in exc_info.value.message + assert "administrator" in exc_info.value.details.lower() + + def test_404_raises_not_found_error(self, client): + """Should raise NotFoundError for 404 response.""" + client.session.request.return_value = self._make_error_response( + 404, "User not found" + ) + + with pytest.raises(NotFoundError) as exc_info: + client.get("/api/users/999") + + assert "not found" in exc_info.value.message.lower() + + def test_400_raises_validation_error(self, client): + """Should raise ValidationError for 400 response.""" + client.session.request.return_value = self._make_error_response( + 400, "Email is required" + ) + + with pytest.raises(ValidationError) as exc_info: + client.post("/api/users", data={}) + + assert "Invalid request" in exc_info.value.message + + def test_422_raises_validation_error(self, client): + """Should raise ValidationError for 422 response.""" + client.session.request.return_value = self._make_error_response( + 422, "Invalid email format" + ) + + with pytest.raises(ValidationError) as exc_info: + client.post("/api/users", data={"email": "invalid"}) + + assert "Validation failed" in exc_info.value.message + + def test_429_raises_rate_limit_error(self, client): + """Should raise RateLimitError for 429 response.""" + client.session.request.return_value = self._make_error_response( + 429, "Too many requests", headers={"Retry-After": "120"} + ) + + with pytest.raises(RateLimitError) as exc_info: + client.get("/api/users") + + assert "Rate limit" in exc_info.value.message + assert exc_info.value.retry_after == 120 + + def test_429_uses_default_retry_after(self, client): + """Should use default retry-after when header missing.""" + client.session.request.return_value = self._make_error_response( + 429, "Too many requests" + ) + + with pytest.raises(RateLimitError) as exc_info: + client.get("/api/users") + + assert exc_info.value.retry_after == 60 # Default + + def test_500_raises_server_error(self, client): + """Should raise ServerError for 500 response.""" + client.session.request.return_value = self._make_error_response( + 500, "Internal server error" + ) + + with pytest.raises(ServerError) as exc_info: + client.get("/api/users") + + assert exc_info.value.status_code == 500 + + def test_503_raises_server_error(self, client): + """Should raise ServerError for 503 response.""" + client.session.request.return_value = self._make_error_response( + 503, "Service unavailable" + ) + + with pytest.raises(ServerError) as exc_info: + client.get("/api/users") + + assert exc_info.value.status_code == 503 + + +class TestNetworkErrors: + """Tests for network error handling.""" + + @pytest.fixture + def client(self): + """Create a client with mocked session.""" + with patch('unityauth_cli.client.requests.Session') as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + mock_session.headers = {} + + client = UnityAuthAPIClient("https://auth.example.com") + client.session = mock_session + yield client + + def test_connection_error_raises_network_error(self, client): + """Should raise NetworkError for connection failures.""" + client.session.request.side_effect = requests.ConnectionError("Connection refused") + + with pytest.raises(NetworkError) as exc_info: + client.get("/api/users") + + assert "Could not connect" in exc_info.value.message + assert "auth.example.com" in exc_info.value.message + + def test_timeout_raises_network_error(self, client): + """Should raise NetworkError for request timeouts.""" + client.session.request.side_effect = requests.Timeout("Request timed out") + + with pytest.raises(NetworkError) as exc_info: + client.get("/api/users") + + assert "timed out" in exc_info.value.message.lower() + + def test_generic_request_exception_raises_network_error(self, client): + """Should raise NetworkError for other request exceptions.""" + client.session.request.side_effect = requests.RequestException("Unknown error") + + with pytest.raises(NetworkError) as exc_info: + client.get("/api/users") + + assert "Network error" in exc_info.value.message + + +class TestErrorMessageExtraction: + """Tests for extracting error messages from responses.""" + + @pytest.fixture + def client(self): + """Create a client with mocked session.""" + with patch('unityauth_cli.client.requests.Session') as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + mock_session.headers = {} + + client = UnityAuthAPIClient("https://auth.example.com") + client.session = mock_session + yield client + + def test_extracts_message_field(self, client): + """Should extract 'message' field from error response.""" + response = MagicMock() + response.ok = False + response.status_code = 400 + response.headers = {} + response.json.return_value = {"message": "Email is required"} + response.text = "some text" + client.session.request.return_value = response + + with pytest.raises(ValidationError) as exc_info: + client.post("/api/users", data={}) + + assert "Email is required" in exc_info.value.message + + def test_extracts_error_field(self, client): + """Should extract 'error' field from error response.""" + response = MagicMock() + response.ok = False + response.status_code = 400 + response.headers = {} + response.json.return_value = {"error": "Bad request format"} + response.text = "some text" + client.session.request.return_value = response + + with pytest.raises(ValidationError) as exc_info: + client.post("/api/users", data={}) + + assert "Bad request format" in exc_info.value.message + + def test_extracts_embedded_message(self, client): + """Should extract '_embedded.message' field from error response.""" + response = MagicMock() + response.ok = False + response.status_code = 400 + response.headers = {} + response.json.return_value = {"_embedded": {"message": "Validation error"}} + response.text = "some text" + client.session.request.return_value = response + + with pytest.raises(ValidationError) as exc_info: + client.post("/api/users", data={}) + + assert "Validation error" in exc_info.value.message + + def test_falls_back_to_response_text(self, client): + """Should use response text when JSON parsing fails.""" + response = MagicMock() + response.ok = False + response.status_code = 400 + response.headers = {} + response.json.side_effect = ValueError("No JSON") + response.text = "Plain text error message" + client.session.request.return_value = response + + with pytest.raises(ValidationError) as exc_info: + client.post("/api/users", data={}) + + assert "Plain text error message" in exc_info.value.message + + +class TestRequestTimeout: + """Tests for request timeout handling.""" + + def test_uses_configured_timeout(self): + """Should use configured timeout for requests.""" + with patch('unityauth_cli.client.requests.Session') as mock_session_class: + mock_session = MagicMock() + mock_session_class.return_value = mock_session + mock_session.headers = {} + + mock_response = MagicMock() + mock_response.ok = True + mock_response.content = b'{}' + mock_response.json.return_value = {} + mock_response.headers = {} + mock_session.request.return_value = mock_response + + client = UnityAuthAPIClient("https://auth.example.com", timeout=45) + client.session = mock_session + + client.get("/api/users") + + call_kwargs = mock_session.request.call_args[1] + assert call_kwargs["timeout"] == 45 diff --git a/unityauth-cli/tests/unit/test_decorators.py b/unityauth-cli/tests/unit/test_decorators.py new file mode 100644 index 0000000..685df95 --- /dev/null +++ b/unityauth-cli/tests/unit/test_decorators.py @@ -0,0 +1,235 @@ +"""Unit tests for CLI decorators (require_auth, require_config).""" + +import pytest +from unittest.mock import MagicMock, patch + +from unityauth_cli.cli import CLIContext, require_auth, require_config +from unityauth_cli.utils.errors import AuthenticationError, ConfigurationError + + +class TestRequireConfig: + """Tests for @require_config decorator.""" + + def test_passes_when_api_url_configured(self): + """Should call wrapped function when API URL is set.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + + called = [] + + @require_config + def my_command(ctx): + called.append(True) + return "success" + + result = my_command(ctx) + + assert called == [True] + assert result == "success" + + def test_raises_when_api_url_not_configured(self): + """Should raise ConfigurationError when API URL is None.""" + ctx = CLIContext() + ctx.api_url = None + + @require_config + def my_command(ctx): + return "should not reach" + + with pytest.raises(ConfigurationError) as exc_info: + my_command(ctx) + + assert "API URL not configured" in exc_info.value.message + assert "config set api_url" in exc_info.value.details + + def test_raises_when_api_url_empty_string(self): + """Should raise ConfigurationError when API URL is empty string.""" + ctx = CLIContext() + ctx.api_url = "" + + @require_config + def my_command(ctx): + return "should not reach" + + with pytest.raises(ConfigurationError): + my_command(ctx) + + def test_preserves_function_metadata(self): + """Should preserve wrapped function's name and docstring.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + + @require_config + def my_special_command(ctx): + """This is a special command.""" + pass + + assert my_special_command.__name__ == "my_special_command" + assert "special command" in my_special_command.__doc__ + + +class TestRequireAuth: + """Tests for @require_auth decorator.""" + + @pytest.fixture + def mock_auth(self): + """Mock the auth module.""" + with patch('unityauth_cli.auth') as mock: + yield mock + + @pytest.fixture + def mock_client_class(self): + """Mock the UnityAuthAPIClient class.""" + with patch('unityauth_cli.client.UnityAuthAPIClient') as mock: + yield mock + + def test_raises_when_api_url_not_configured(self, mock_auth, mock_client_class): + """Should raise ConfigurationError when API URL is None.""" + ctx = CLIContext() + ctx.api_url = None + + @require_auth + def my_command(ctx, client): + return "should not reach" + + with pytest.raises(ConfigurationError) as exc_info: + my_command(ctx) + + assert "API URL not configured" in exc_info.value.message + + def test_raises_when_not_authenticated(self, mock_auth, mock_client_class): + """Should raise AuthenticationError when no token exists.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + ctx.config = None + + mock_auth.get_token.return_value = None + + @require_auth + def my_command(ctx, client): + return "should not reach" + + with pytest.raises(AuthenticationError) as exc_info: + my_command(ctx) + + assert "Not authenticated" in exc_info.value.message + assert "unityauth login" in exc_info.value.details + + def test_creates_client_and_passes_to_command(self, mock_auth, mock_client_class): + """Should create API client and pass it to wrapped function.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + ctx.config = None + + mock_auth.get_token.return_value = "test-token" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + received_client = [] + + @require_auth + def my_command(ctx, client): + received_client.append(client) + return "success" + + result = my_command(ctx) + + assert result == "success" + assert received_client[0] is mock_client + mock_client_class.assert_called_once_with( + "https://auth.example.com", + token="test-token", + timeout=30 + ) + + def test_uses_timeout_from_config(self, mock_auth, mock_client_class): + """Should use timeout from configuration.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + ctx.config = MagicMock() + ctx.config.get.return_value = 60 # Custom timeout + + mock_auth.get_token.return_value = "test-token" + + @require_auth + def my_command(ctx, client): + return "success" + + my_command(ctx) + + mock_client_class.assert_called_once_with( + "https://auth.example.com", + token="test-token", + timeout=60 + ) + + def test_uses_default_timeout_when_config_none(self, mock_auth, mock_client_class): + """Should use default timeout when config is None.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + ctx.config = None + + mock_auth.get_token.return_value = "test-token" + + @require_auth + def my_command(ctx, client): + return "success" + + my_command(ctx) + + mock_client_class.assert_called_once_with( + "https://auth.example.com", + token="test-token", + timeout=30 # Default + ) + + def test_passes_additional_args_to_command(self, mock_auth, mock_client_class): + """Should pass additional arguments to wrapped function.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + ctx.config = None + + mock_auth.get_token.return_value = "test-token" + + received_args = [] + + @require_auth + def my_command(ctx, email, tenant_id, client): + received_args.extend([email, tenant_id]) + return "success" + + my_command(ctx, "test@example.com", 123) + + assert received_args == ["test@example.com", 123] + + def test_passes_keyword_args_to_command(self, mock_auth, mock_client_class): + """Should pass keyword arguments to wrapped function.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + ctx.config = None + + mock_auth.get_token.return_value = "test-token" + + received_kwargs = {} + + @require_auth + def my_command(ctx, client, verbose=False, format="table"): + received_kwargs.update({"verbose": verbose, "format": format}) + return "success" + + my_command(ctx, verbose=True, format="json") + + assert received_kwargs == {"verbose": True, "format": "json"} + + def test_preserves_function_metadata(self, mock_auth, mock_client_class): + """Should preserve wrapped function's name and docstring.""" + ctx = CLIContext() + ctx.api_url = "https://auth.example.com" + + @require_auth + def create_user(ctx, client): + """Create a new user account.""" + pass + + assert create_user.__name__ == "create_user" + assert "Create a new user" in create_user.__doc__