From 55ccdc8a1eefec93f163f32a95de3ed466bbb8b1 Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Mon, 12 Jan 2026 23:28:55 +0100 Subject: [PATCH] Add sensitive variable support with GNOME Keyring integration --- SENSITIVE_VARIABLES_UI_GUIDE.md | 230 ++++++++++++++++++++++ SENSITIVE_VARIABLES_USAGE.md | 271 ++++++++++++++++++++++++++ cz.bugsy.roster.json | 3 +- example_sensitive_vars.py | 158 +++++++++++++++ meson.build | 2 +- src/environments_dialog.py | 26 ++- src/meson.build | 1 + src/models.py | 9 +- src/project_manager.py | 257 ++++++++++++++++++++++++ src/request_tab_widget.py | 28 +-- src/secret_manager.py | 322 +++++++++++++++++++++++++++++++ src/widgets/variable-data-row.ui | 11 ++ src/widgets/variable_data_row.py | 79 +++++++- src/window.py | 10 + 14 files changed, 1379 insertions(+), 28 deletions(-) create mode 100644 SENSITIVE_VARIABLES_UI_GUIDE.md create mode 100644 SENSITIVE_VARIABLES_USAGE.md create mode 100644 example_sensitive_vars.py create mode 100644 src/secret_manager.py diff --git a/SENSITIVE_VARIABLES_UI_GUIDE.md b/SENSITIVE_VARIABLES_UI_GUIDE.md new file mode 100644 index 0000000..a3045f2 --- /dev/null +++ b/SENSITIVE_VARIABLES_UI_GUIDE.md @@ -0,0 +1,230 @@ +# Sensitive Variables - UI User Guide + +## Overview + +Roster now supports marking variables as **sensitive** to store their values securely in GNOME Keyring instead of plain text. This guide explains how to use this feature in the UI. + +## Features + +### 🔒 Lock Icon Toggle + +Each variable in the Environments dialog has a lock icon button: +- **Unlocked** (🔓 `channel-insecure-symbolic`) - Variable is stored in plain JSON +- **Locked** (🔒 `channel-secure-symbolic`) - Variable is stored encrypted in GNOME Keyring + +### Visual Indicators + +1. **Lock Icon Color/State** + - Inactive (gray): Non-sensitive variable + - Active (accent color): Sensitive variable + +2. **Variable Name Styling** + - Regular variables: Normal text + - Sensitive variables: Colored with accent color and bold weight + +3. **Value Entry Fields** + - Regular variables: Show plain text + - Sensitive variables: Show bullets (••••••) instead of text + +## How to Use + +### Step 1: Open Environments Dialog + +1. Select a project from the sidebar +2. Click the "Environments" button in the header bar +3. The dialog shows a table with: + - Rows: Variable names + - Columns: Environment values + +### Step 2: Create Variables + +If you haven't already: +1. Click the **"Add Variable"** button (bottom-left) +2. Enter a variable name (e.g., `api_key`) +3. Click "Add" + +### Step 3: Mark Variable as Sensitive + +**Option A: Before Entering Values (Recommended)** +1. Find the variable row in the table +2. Click the **lock icon** next to the variable name +3. Icon changes from 🔓 to 🔒 +4. Now enter your secret values - they go directly to keyring + +**Option B: After Entering Values (Migration)** +1. If you already entered sensitive values in plain text +2. Click the lock icon to mark as sensitive +3. Values are automatically moved from JSON to GNOME Keyring +4. JSON file now shows empty placeholders + +### Step 4: Enter Sensitive Values + +1. Click in the value entry field for any environment +2. Type your secret value +3. You'll see bullets (••••••) instead of text +4. Value is automatically saved to GNOME Keyring + +### Step 5: Use in Requests + +Sensitive variables work exactly like regular variables: +``` +URL: {{base_url}}/users +Headers: + Authorization: Bearer {{api_key}} +``` + +When you send the request, values are automatically retrieved from the keyring and substituted. + +## UI Walkthrough + +### Example: Storing a GitHub Token + +**Before (Insecure - in plain JSON):** +``` +Variables Table: +┌─────────────┬──────────────────┬──────────────────┐ +│ Variable │ Production │ Development │ +├─────────────┼──────────────────┼──────────────────┤ +│ base_url 🔓 │ api.github.com │ api.dev.local │ +│ token 🔓 │ ghp_abc123... │ ghp_dev456... │ ← VISIBLE! +└─────────────┴──────────────────┴──────────────────┘ +``` + +**After (Secure - in GNOME Keyring):** +``` +Variables Table: +┌─────────────┬──────────────────┬──────────────────┐ +│ Variable │ Production │ Development │ +├─────────────┼──────────────────┼──────────────────┤ +│ base_url 🔓 │ api.github.com │ api.dev.local │ +│ token 🔒 │ •••••••••••• │ •••••••••••• │ ← HIDDEN! +└─────────────┴──────────────────┴──────────────────┘ +``` + +## Tooltips + +Hover over the lock icon to see: +- **Unlocked**: "Not sensitive (stored in JSON) - Click to mark as sensitive" +- **Locked**: "Sensitive (stored in keyring) - Click to make non-sensitive" + +## Making a Variable Non-Sensitive Again + +If you accidentally marked a variable as sensitive: +1. Click the lock icon again (🔒 → 🔓) +2. Values are moved from keyring back to JSON +3. You can now see the values in plain text + +**Warning**: Only do this if the variable truly doesn't contain secrets! + +## Viewing Secrets in GNOME Keyring + +Want to verify your secrets are stored? +1. Open **"Passwords and Keys"** application (Seahorse) +2. Look under **"Login"** keyring +3. Find entries labeled: `Roster: ProjectName/EnvironmentName/VariableName` + +Example: +``` +Login Keyring + └─ Roster: My API Project/Production/api_key + └─ Roster: My API Project/Development/api_key +``` + +## Best Practices + +### ✅ DO Mark as Sensitive: +- `api_key` - API keys +- `secret_key` - Secret keys +- `password` - Passwords +- `token` - Bearer tokens, OAuth tokens +- `client_secret` - OAuth client secrets +- `private_key` - Private keys +- Any variable containing credentials + +### ❌ DON'T Mark as Sensitive: +- `base_url` - API endpoints +- `env` - Environment names +- `region` - Cloud regions +- `timeout` - Timeout values +- `version` - API versions +- Any non-secret configuration + +### Workflow Tips + +1. **Create variables first, then mark as sensitive** + - Add variable + - Click lock icon + - Enter values (they go directly to keyring) + +2. **Use descriptive names** + - ✅ `stripe_secret_key` + - ❌ `key1` + +3. **Group related variables** + ``` + base_url 🔓 + api_key 🔒 + api_secret 🔒 + timeout 🔓 + ``` + +4. **Audit regularly** + - Check which variables are marked as sensitive + - Ensure all secrets are locked (🔒) + +## Troubleshooting + +### Q: I clicked the lock but it didn't work +**A:** Check the application logs. The keyring might be locked. Unlock it with your login password. + +### Q: Can I see my secret values after marking as sensitive? +**A:** The UI shows bullets (••••••) for security. To view: +- Temporarily unlock (click 🔒 → 🔓) +- Or use "Passwords and Keys" app to view in keyring + +### Q: What happens if I delete a sensitive variable? +**A:** Both the JSON entry AND the keyring secret are deleted automatically. + +### Q: What happens if I rename a sensitive variable? +**A:** The keyring secret is automatically renamed. No data is lost. + +### Q: What happens if I delete an environment? +**A:** All keyring secrets for that environment are deleted automatically. + +### Q: What happens if I delete a project? +**A:** ALL keyring secrets for that project are deleted automatically. + +## Security Notes + +- ✅ Secrets are encrypted with your login password +- ✅ Automatically unlocked when you log in +- ✅ Protected by OS-level security +- ✅ Same security as browser passwords, WiFi passwords, SSH keys +- ⚠️ Non-sensitive variables are still in plain JSON +- ⚠️ Mark variables as sensitive BEFORE entering secret values + +## Keyboard Shortcuts + +- **Tab**: Move between value fields +- **Enter**: Confirm value (auto-saved) +- **Escape**: Close dialog + +## Visual Design + +The UI uses the following visual cues: + +1. **Lock Icon State** + - Gray/inactive = Plain JSON storage + - Colored/active = Encrypted keyring storage + +2. **Variable Name Color** + - Sensitive variables have accent color and bold font + +3. **Password Entry** + - Sensitive variable values show as bullets + - Same UX as password fields throughout GNOME + +4. **Consistent with GNOME HIG** + - Follows GNOME Human Interface Guidelines + - Familiar patterns (lock icon = security) + - Accessible and keyboard-navigable diff --git a/SENSITIVE_VARIABLES_USAGE.md b/SENSITIVE_VARIABLES_USAGE.md new file mode 100644 index 0000000..4a6b133 --- /dev/null +++ b/SENSITIVE_VARIABLES_USAGE.md @@ -0,0 +1,271 @@ +# Sensitive Variables - Usage Guide + +This document explains how to use the GNOME Keyring integration for storing sensitive variable values securely. + +## Overview + +Roster now supports marking variables as **sensitive**, which stores their values encrypted in GNOME Keyring instead of plain text in the JSON file. + +**Use sensitive variables for:** +- API keys (`api_key`, `secret_key`) +- Authentication tokens (`bearer_token`, `oauth_token`) +- Passwords (`password`, `db_password`) +- Any secret credentials + +**Use regular variables for:** +- Base URLs (`base_url`, `api_endpoint`) +- Environment names (`env`, `region`) +- Non-sensitive configuration values + +## Storage + +### Regular Variables +Stored in: `~/.local/share/cz.bugsy.roster/requests.json` +```json +{ + "projects": [{ + "variable_names": ["base_url", "api_key"], + "sensitive_variables": [], // empty = not sensitive + "environments": [{ + "variables": { + "base_url": "https://api.example.com", // visible in JSON + "api_key": "secret-key-123" // visible (NOT SAFE!) + } + }] + }] +} +``` + +### Sensitive Variables +Stored in: GNOME Keyring (encrypted) +```json +{ + "projects": [{ + "variable_names": ["base_url", "api_key"], + "sensitive_variables": ["api_key"], // marked as sensitive + "environments": [{ + "variables": { + "base_url": "https://api.example.com", // visible in JSON + "api_key": "" // empty placeholder + } + }] + }] +} +``` + +The actual value of `api_key` is stored encrypted in GNOME Keyring and automatically retrieved when needed. + +## Code Examples + +### Basic Usage + +```python +from roster.project_manager import ProjectManager + +pm = ProjectManager() +project_id = "your-project-id" +env_id = "your-environment-id" + +# Mark a variable as sensitive (moves existing values to keyring) +pm.mark_variable_as_sensitive(project_id, "api_key") + +# Set a sensitive variable value (goes to keyring) +pm.set_variable_value(project_id, env_id, "api_key", "my-secret-key-123") + +# Get a sensitive variable value (retrieved from keyring) +value = pm.get_variable_value(project_id, env_id, "api_key") +# Returns: "my-secret-key-123" + +# Check if variable is sensitive +is_sensitive = pm.is_variable_sensitive(project_id, "api_key") +# Returns: True +``` + +### Using in Request Substitution + +```python +# Get environment with ALL values (including secrets from keyring) +environment = pm.get_environment_with_secrets(project_id, env_id) + +# Now use this environment for variable substitution +from roster.variable_substitution import VariableSubstitution + +vs = VariableSubstitution() +request = HttpRequest( + method="GET", + url="{{base_url}}/users", + headers={"Authorization": "Bearer {{api_key}}"}, + body="" +) + +substituted_request, undefined = vs.substitute_request(request, environment) +# Result: +# url = "https://api.example.com/users" +# headers = {"Authorization": "Bearer my-secret-key-123"} +``` + +### Mark Variable as Sensitive + +```python +# Existing variable with values in all environments +pm.mark_variable_as_sensitive(project_id, "token") + +# This will: +# 1. Add "token" to project.sensitive_variables list +# 2. Move all environment values to GNOME Keyring +# 3. Clear values in JSON (replaced with empty strings) +``` + +### Mark Variable as Non-Sensitive + +```python +# Move back from keyring to JSON +pm.mark_variable_as_nonsensitive(project_id, "base_url") + +# This will: +# 1. Remove "base_url" from project.sensitive_variables list +# 2. Move all environment values from keyring to JSON +# 3. Delete secrets from keyring +``` + +### Variable Operations (Automatic Secret Handling) + +```python +# Rename a sensitive variable +pm.rename_variable(project_id, "old_token", "new_token") +# Automatically renames in both JSON AND keyring + +# Delete a sensitive variable +pm.delete_variable(project_id, "api_key") +# Automatically deletes from both JSON AND keyring + +# Delete an environment +pm.delete_environment(project_id, env_id) +# Automatically deletes all secrets for that environment + +# Delete a project +pm.delete_project(project_id) +# Automatically deletes ALL secrets for that project +``` + +## Integration with UI + +When implementing UI for sensitive variables: + +### Variable List Display + +```python +# Show lock icon for sensitive variables +for var_name in project.variable_names: + is_sensitive = var_name in project.sensitive_variables + icon = "🔒" if is_sensitive else "" + label = f"{icon} {var_name}" +``` + +### Variable Value Entry + +```python +# Use password entry for sensitive variables +entry = Gtk.Entry() +if var_name in project.sensitive_variables: + entry.set_visibility(False) # Show bullets instead of text + entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) +``` + +### Context Menu + +```python +# Right-click menu on variable +menu = Gio.Menu() + +if pm.is_variable_sensitive(project_id, var_name): + menu.append("Mark as Non-Sensitive", f"app.mark-nonsensitive::{var_name}") +else: + menu.append("Mark as Sensitive", f"app.mark-sensitive::{var_name}") +``` + +## How It Works + +### GNOME Keyring Schema + +Each secret is stored with these attributes: +```python +{ + "project_id": "uuid-of-project", + "environment_id": "uuid-of-environment", + "variable_name": "api_key" +} +``` + +Label shown in Seahorse (Passwords and Keys app): +``` +"Roster: ProjectName/EnvironmentName/variable_name" +``` + +### Viewing Secrets in Seahorse + +1. Open "Passwords and Keys" application +2. Look under "Login" keyring +3. Find entries labeled "Roster: ..." +4. These are your sensitive variable values + +### Security + +- Encrypted at rest with your login password +- Automatically unlocked when you log in +- Protected by OS-level security +- Uses the same secure backend as browser passwords, WiFi passwords, SSH keys + +## Migration Guide + +If you have existing variables with sensitive data: + +```python +project_id = "your-project-id" +sensitive_vars = ["api_key", "token", "password", "secret"] + +for var_name in sensitive_vars: + pm.mark_variable_as_sensitive(project_id, var_name) + +# Done! All values are now encrypted in GNOME Keyring +``` + +## Error Handling + +```python +from roster.secret_manager import get_secret_manager + +sm = get_secret_manager() + +# store_secret returns bool +success = sm.store_secret(project_id, env_id, "api_key", "value") +if not success: + # Handle error (check logs) + print("Failed to store secret") + +# retrieve_secret returns None on failure +value = sm.retrieve_secret(project_id, env_id, "api_key") +if value is None: + # Secret not found or error occurred + print("Secret not found") +``` + +## Best Practices + +1. **Mark variables as sensitive BEFORE entering values** + - This ensures values never touch the JSON file + +2. **Use descriptive variable names** + - `github_token` instead of `token` + - `stripe_api_key` instead of `key` + +3. **Don't commit the JSON file with sensitive data** + - If you accidentally stored secrets in JSON, mark as sensitive to move them + +4. **Regular variables are fine for most things** + - Only use sensitive variables for actual secrets + - Base URLs, regions, etc. don't need encryption + +5. **Backup your GNOME Keyring** + - Sensitive variables are stored in your system keyring + - Backup `~/.local/share/keyrings/` if needed diff --git a/cz.bugsy.roster.json b/cz.bugsy.roster.json index 8ac31e4..f7ed017 100644 --- a/cz.bugsy.roster.json +++ b/cz.bugsy.roster.json @@ -9,7 +9,8 @@ "--share=ipc", "--socket=fallback-x11", "--device=dri", - "--socket=wayland" + "--socket=wayland", + "--talk-name=org.freedesktop.secrets" ], "cleanup" : [ "/include", diff --git a/example_sensitive_vars.py b/example_sensitive_vars.py new file mode 100644 index 0000000..9b170ec --- /dev/null +++ b/example_sensitive_vars.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Example script demonstrating sensitive variable usage. + +This script shows how to: +1. Create a project with variables +2. Mark some variables as sensitive +3. Set values for both regular and sensitive variables +4. Retrieve values from both storages +5. Use variables in request substitution + +Run this from the project root after building the app. +""" + +import sys +import os + +# Add src to path for testing +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from roster.project_manager import ProjectManager +from roster.models import HttpRequest +from roster.variable_substitution import VariableSubstitution + + +def main(): + print("=" * 60) + print("Sensitive Variables Example") + print("=" * 60) + + pm = ProjectManager() + + # Create a test project + print("\n1. Creating test project...") + project = pm.add_project("API Test Project") + project_id = project.id + print(f" Created project: {project.name} (ID: {project_id})") + + # Add environments + print("\n2. Adding environments...") + env_prod = pm.add_environment(project_id, "Production") + env_dev = pm.add_environment(project_id, "Development") + print(f" - Production (ID: {env_prod.id})") + print(f" - Development (ID: {env_dev.id})") + + # Add variables + print("\n3. Adding variables...") + pm.add_variable(project_id, "base_url") + pm.add_variable(project_id, "api_key") + pm.add_variable(project_id, "timeout") + print(" - base_url (regular)") + print(" - api_key (regular for now)") + print(" - timeout (regular)") + + # Set values for regular variables + print("\n4. Setting values for regular variables...") + pm.set_variable_value(project_id, env_prod.id, "base_url", "https://api.example.com") + pm.set_variable_value(project_id, env_dev.id, "base_url", "https://dev.api.example.com") + pm.set_variable_value(project_id, env_prod.id, "timeout", "30") + pm.set_variable_value(project_id, env_dev.id, "timeout", "60") + print(" ✓ Set base_url for both environments") + print(" ✓ Set timeout for both environments") + + # Set API key values (still regular) + print("\n5. Setting API key values (in JSON - NOT SECURE YET)...") + pm.set_variable_value(project_id, env_prod.id, "api_key", "prod-secret-key-12345") + pm.set_variable_value(project_id, env_dev.id, "api_key", "dev-secret-key-67890") + print(" ⚠ API keys stored in plain JSON file!") + + # Mark API key as sensitive + print("\n6. Marking api_key as SENSITIVE...") + success = pm.mark_variable_as_sensitive(project_id, "api_key") + if success: + print(" ✓ api_key is now stored in GNOME Keyring (encrypted)") + print(" ✓ Values moved from JSON to keyring") + print(" ✓ JSON now contains empty placeholders") + else: + print(" ✗ Failed to mark as sensitive") + return + + # Verify storage + print("\n7. Verifying storage...") + is_sensitive = pm.is_variable_sensitive(project_id, "api_key") + print(f" - api_key is sensitive: {is_sensitive}") + + # Retrieve values + print("\n8. Retrieving values...") + base_url_prod = pm.get_variable_value(project_id, env_prod.id, "base_url") + api_key_prod = pm.get_variable_value(project_id, env_prod.id, "api_key") + api_key_dev = pm.get_variable_value(project_id, env_dev.id, "api_key") + + print(f" - Production base_url: {base_url_prod}") + print(f" - Production api_key: {api_key_prod} (from keyring)") + print(f" - Development api_key: {api_key_dev} (from keyring)") + + # Use in request substitution + print("\n9. Using in request substitution...") + request = HttpRequest( + method="GET", + url="{{base_url}}/users", + headers={ + "Authorization": "Bearer {{api_key}}", + "X-Timeout": "{{timeout}}" + }, + body="" + ) + print(f" Original request URL: {request.url}") + print(f" Original request headers: {request.headers}") + + # Get environment with secrets + env_with_secrets = pm.get_environment_with_secrets(project_id, env_prod.id) + + # Substitute variables + vs = VariableSubstitution() + substituted_request, undefined = vs.substitute_request(request, env_with_secrets) + + print(f"\n Substituted request URL: {substituted_request.url}") + print(f" Substituted headers: {substituted_request.headers}") + print(f" Undefined variables: {undefined}") + + # Demonstrate file storage + print("\n10. Checking file storage...") + projects = pm.load_projects() + for p in projects: + if p.id == project_id: + print(f" Variables: {p.variable_names}") + print(f" Sensitive variables: {p.sensitive_variables}") + for env in p.environments: + if env.id == env_prod.id: + print(f" Production variables in JSON: {env.variables}") + print(f" Notice: api_key is empty (value in keyring)") + + # Cleanup + print("\n11. Cleanup...") + response = input(" Delete test project? (y/n): ") + if response.lower() == 'y': + pm.delete_project(project_id) + print(" ✓ Project deleted (secrets also removed from keyring)") + else: + print(f" Project kept. ID: {project_id}") + print(" You can view secrets in 'Passwords and Keys' app") + + print("\n" + "=" * 60) + print("Example completed!") + print("=" * 60) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nInterrupted by user") + sys.exit(1) + except Exception as e: + print(f"\n\nError: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/meson.build b/meson.build index 7a668c7..76329c0 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('roster', - version: '0.4.9', + version: '0.5.0', meson_version: '>= 1.0.0', default_options: [ 'warning_level=2', 'werror=false', ], ) diff --git a/src/environments_dialog.py b/src/environments_dialog.py index a808fc6..9765aa3 100644 --- a/src/environments_dialog.py +++ b/src/environments_dialog.py @@ -80,6 +80,8 @@ class EnvironmentsDialog(Adw.Dialog): var_name, self.project.environments, self.size_group, + self.project, + self.project_manager, update_timestamp=update_timestamp ) row.connect('variable-changed', @@ -88,6 +90,8 @@ class EnvironmentsDialog(Adw.Dialog): self._on_variable_remove, var_name) row.connect('value-changed', self._on_value_changed, var_name) + row.connect('sensitivity-changed', + self._on_sensitivity_changed, var_name) self.table_container.append(row) self.data_rows.append(row) @@ -273,9 +277,29 @@ class EnvironmentsDialog(Adw.Dialog): def _on_value_changed(self, widget, env_id, value, var_name): """Handle value change in table cell.""" - self.project_manager.update_environment_variable(self.project.id, env_id, var_name, value) + # Note: value is already stored by VariableDataRow using set_variable_value + # This handler is just for emitting the update signal self.emit('environments-updated') + def _on_sensitivity_changed(self, widget, is_sensitive, var_name): + """Handle variable sensitivity toggle.""" + if is_sensitive: + # Mark as sensitive (move to keyring) + success = self.project_manager.mark_variable_as_sensitive(self.project.id, var_name) + if success: + # Reload and refresh UI + self._reload_project() + self._populate_table() + self.emit('environments-updated') + else: + # Mark as non-sensitive (move to JSON) + success = self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name) + if success: + # Reload and refresh UI + self._reload_project() + self._populate_table() + self.emit('environments-updated') + def _reload_project(self): """Reload project data from manager.""" projects = self.project_manager.load_projects() diff --git a/src/meson.build b/src/meson.build index dc7ff9c..e6b6d16 100644 --- a/src/meson.build +++ b/src/meson.build @@ -35,6 +35,7 @@ roster_sources = [ 'http_client.py', 'history_manager.py', 'project_manager.py', + 'secret_manager.py', 'tab_manager.py', 'constants.py', 'icon_picker_dialog.py', diff --git a/src/models.py b/src/models.py index 3c8aef4..75fa0c5 100644 --- a/src/models.py +++ b/src/models.py @@ -181,6 +181,7 @@ class Project: icon: str = "folder-symbolic" # Icon name variable_names: List[str] = None # List of variable names defined for this project environments: List[Environment] = None # List of environments + sensitive_variables: List[str] = None # List of variable names marked as sensitive (stored in keyring) def __post_init__(self): """Initialize optional fields.""" @@ -188,6 +189,8 @@ class Project: self.variable_names = [] if self.environments is None: self.environments = [] + if self.sensitive_variables is None: + self.sensitive_variables = [] def to_dict(self): """Convert to dictionary for JSON serialization.""" @@ -198,7 +201,8 @@ class Project: 'created_at': self.created_at, 'icon': self.icon, 'variable_names': self.variable_names, - 'environments': [env.to_dict() for env in self.environments] + 'environments': [env.to_dict() for env in self.environments], + 'sensitive_variables': self.sensitive_variables } @classmethod @@ -211,7 +215,8 @@ class Project: created_at=data['created_at'], icon=data.get('icon', 'folder-symbolic'), # Default for old data variable_names=data.get('variable_names', []), - environments=[Environment.from_dict(e) for e in data.get('environments', [])] + environments=[Environment.from_dict(e) for e in data.get('environments', [])], + sensitive_variables=data.get('sensitive_variables', []) # Default for old data ) diff --git a/src/project_manager.py b/src/project_manager.py index c427c92..94cb35f 100644 --- a/src/project_manager.py +++ b/src/project_manager.py @@ -28,6 +28,7 @@ import gi gi.require_version('GLib', '2.0') from gi.repository import GLib from .models import Project, SavedRequest, HttpRequest, Environment +from .secret_manager import get_secret_manager logger = logging.getLogger(__name__) @@ -134,6 +135,12 @@ class ProjectManager: def delete_project(self, project_id: str): """Delete a project and all its requests.""" projects = self.load_projects() + secret_manager = get_secret_manager() + + # Delete all secrets for this project + secret_manager.delete_all_project_secrets(project_id) + + # Remove project from list projects = [p for p in projects if p.id != project_id] self.save_projects(projects) @@ -235,8 +242,13 @@ class ProjectManager: def delete_environment(self, project_id: str, env_id: str): """Delete an environment.""" projects = self.load_projects() + secret_manager = get_secret_manager() + for p in projects: if p.id == project_id: + # Delete all secrets for this environment + secret_manager.delete_all_environment_secrets(project_id, env_id) + # Remove environment from list p.environments = [e for e in p.environments if e.id != env_id] break self.save_projects(projects) @@ -257,12 +269,22 @@ class ProjectManager: def rename_variable(self, project_id: str, old_name: str, new_name: str): """Rename a variable in the project and all environments.""" projects = self.load_projects() + secret_manager = get_secret_manager() + for p in projects: if p.id == project_id: # Update variable_names list if old_name in p.variable_names: idx = p.variable_names.index(old_name) p.variable_names[idx] = new_name + + # Update sensitive_variables list if applicable + if old_name in p.sensitive_variables: + idx_sensitive = p.sensitive_variables.index(old_name) + p.sensitive_variables[idx_sensitive] = new_name + # Rename secrets in keyring + secret_manager.rename_variable_secrets(project_id, old_name, new_name) + # Update all environments for env in p.environments: if old_name in env.variables: @@ -273,11 +295,21 @@ class ProjectManager: def delete_variable(self, project_id: str, variable_name: str): """Delete a variable from the project and all environments.""" projects = self.load_projects() + secret_manager = get_secret_manager() + for p in projects: if p.id == project_id: # Remove from variable_names if variable_name in p.variable_names: p.variable_names.remove(variable_name) + + # Remove from sensitive_variables if applicable + if variable_name in p.sensitive_variables: + p.sensitive_variables.remove(variable_name) + # Delete all secrets for this variable + for env in p.environments: + secret_manager.delete_secret(project_id, env.id, variable_name) + # Remove from all environments for env in p.environments: env.variables.pop(variable_name, None) @@ -316,3 +348,228 @@ class ProjectManager: break break self.save_projects(projects) + + # ========== Sensitive Variable Management ========== + + def mark_variable_as_sensitive(self, project_id: str, variable_name: str) -> bool: + """ + Mark a variable as sensitive (move values from JSON to keyring). + + This will: + 1. Add variable to sensitive_variables list + 2. Move all environment values to keyring + 3. Clear values from JSON (store empty string as placeholder) + + Args: + project_id: Project ID + variable_name: Variable name to mark as sensitive + + Returns: + True if successful + """ + projects = self.load_projects() + secret_manager = get_secret_manager() + + for p in projects: + if p.id == project_id: + # Add to sensitive list if not already there + if variable_name not in p.sensitive_variables: + p.sensitive_variables.append(variable_name) + + # Move all environment values to keyring + for env in p.environments: + if variable_name in env.variables: + value = env.variables[variable_name] + # Store in keyring + secret_manager.store_secret( + project_id=project_id, + environment_id=env.id, + variable_name=variable_name, + value=value, + project_name=p.name, + environment_name=env.name + ) + # Clear from JSON (keep empty placeholder) + env.variables[variable_name] = "" + + self.save_projects(projects) + return True + + return False + + def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str) -> bool: + """ + Mark a variable as non-sensitive (move values from keyring to JSON). + + This will: + 1. Remove variable from sensitive_variables list + 2. Move all environment values from keyring to JSON + 3. Delete values from keyring + + Args: + project_id: Project ID + variable_name: Variable name to mark as non-sensitive + + Returns: + True if successful + """ + projects = self.load_projects() + secret_manager = get_secret_manager() + + for p in projects: + if p.id == project_id: + # Remove from sensitive list + if variable_name in p.sensitive_variables: + p.sensitive_variables.remove(variable_name) + + # Move all environment values from keyring to JSON + for env in p.environments: + # Retrieve from keyring + value = secret_manager.retrieve_secret( + project_id=project_id, + environment_id=env.id, + variable_name=variable_name + ) + # Store in JSON + env.variables[variable_name] = value or "" + # Delete from keyring + secret_manager.delete_secret( + project_id=project_id, + environment_id=env.id, + variable_name=variable_name + ) + + self.save_projects(projects) + return True + + return False + + def is_variable_sensitive(self, project_id: str, variable_name: str) -> bool: + """Check if a variable is marked as sensitive.""" + projects = self.load_projects() + for p in projects: + if p.id == project_id: + return variable_name in p.sensitive_variables + return False + + def get_variable_value(self, project_id: str, env_id: str, variable_name: str) -> str: + """ + Get variable value from appropriate storage (keyring or JSON). + + This is a convenience method that handles both sensitive and non-sensitive variables. + + Args: + project_id: Project ID + env_id: Environment ID + variable_name: Variable name + + Returns: + Variable value (empty string if not found) + """ + projects = self.load_projects() + + for p in projects: + if p.id == project_id: + # Check if sensitive + if variable_name in p.sensitive_variables: + # Retrieve from keyring + secret_manager = get_secret_manager() + value = secret_manager.retrieve_secret( + project_id=project_id, + environment_id=env_id, + variable_name=variable_name + ) + return value or "" + else: + # Retrieve from JSON + for env in p.environments: + if env.id == env_id: + return env.variables.get(variable_name, "") + + return "" + + def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str): + """ + Set variable value in appropriate storage (keyring or JSON). + + This is a convenience method that handles both sensitive and non-sensitive variables. + + Args: + project_id: Project ID + env_id: Environment ID + variable_name: Variable name + value: Value to set + """ + projects = self.load_projects() + secret_manager = get_secret_manager() + + for p in projects: + if p.id == project_id: + # Check if sensitive + if variable_name in p.sensitive_variables: + # Store in keyring + for env in p.environments: + if env.id == env_id: + secret_manager.store_secret( + project_id=project_id, + environment_id=env.id, + variable_name=variable_name, + value=value, + project_name=p.name, + environment_name=env.name + ) + # Keep empty placeholder in JSON + env.variables[variable_name] = "" + break + else: + # Store in JSON + for env in p.environments: + if env.id == env_id: + env.variables[variable_name] = value + break + + self.save_projects(projects) + return + + def get_environment_with_secrets(self, project_id: str, env_id: str) -> Optional[Environment]: + """ + Get an environment with all variable values (including secrets from keyring). + + This is useful for variable substitution - it returns a complete Environment + object with all values populated from both JSON and keyring. + + Args: + project_id: Project ID + env_id: Environment ID + + Returns: + Environment object with all values, or None if not found + """ + projects = self.load_projects() + secret_manager = get_secret_manager() + + for p in projects: + if p.id == project_id: + for env in p.environments: + if env.id == env_id: + # Create a copy of the environment + complete_env = Environment( + id=env.id, + name=env.name, + variables=env.variables.copy(), + created_at=env.created_at + ) + + # Fill in sensitive variable values from keyring + for var_name in p.sensitive_variables: + value = secret_manager.retrieve_secret( + project_id=project_id, + environment_id=env_id, + variable_name=var_name + ) + if value is not None: + complete_env.variables[var_name] = value + + return complete_env + + return None diff --git a/src/request_tab_widget.py b/src/request_tab_widget.py index 052aee8..3d695a7 100644 --- a/src/request_tab_widget.py +++ b/src/request_tab_widget.py @@ -1319,27 +1319,19 @@ class RequestTabWidget(Gtk.Box): self._update_variable_indicators() def get_selected_environment(self): - """Get the currently selected environment object.""" + """ + Get the currently selected environment object with all values. + + This includes sensitive variable values from the keyring. + """ if not self.selected_environment_id or not self.project_manager or not self.project_id: return None - # Load projects - projects = self.project_manager.load_projects() - project = None - for p in projects: - if p.id == self.project_id: - project = p - break - - if not project: - return None - - # Find environment - for env in project.environments: - if env.id == self.selected_environment_id: - return env - - return None + # Get environment with secrets (includes values from keyring) + return self.project_manager.get_environment_with_secrets( + self.project_id, + self.selected_environment_id + ) def _detect_undefined_variables(self): """Detect undefined variables in the current request. Returns set of undefined variable names.""" diff --git a/src/secret_manager.py b/src/secret_manager.py new file mode 100644 index 0000000..315033e --- /dev/null +++ b/src/secret_manager.py @@ -0,0 +1,322 @@ +# secret_manager.py +# +# Copyright 2025 Pavel Baksy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +""" +Manager for storing and retrieving sensitive variable values using GNOME Keyring. + +This module provides a clean interface to libsecret for storing environment +variable values that contain sensitive data (API keys, passwords, tokens). + +Each secret is identified by: +- project_id: UUID of the project +- environment_id: UUID of the environment +- variable_name: Name of the variable +""" + +import gi +gi.require_version('Secret', '1') +from gi.repository import Secret, GLib +import logging + +logger = logging.getLogger(__name__) + +# Define schema for Roster environment variables +# This separates them from other secrets in GNOME Keyring +ROSTER_SCHEMA = Secret.Schema.new( + "cz.bugsy.roster.EnvironmentVariable", + Secret.SchemaFlags.NONE, + { + "project_id": Secret.SchemaAttributeType.STRING, + "environment_id": Secret.SchemaAttributeType.STRING, + "variable_name": Secret.SchemaAttributeType.STRING, + } +) + + +class SecretManager: + """Manages sensitive variable storage using GNOME Keyring via libsecret.""" + + def __init__(self): + """Initialize the secret manager.""" + self.schema = ROSTER_SCHEMA + + def store_secret(self, project_id: str, environment_id: str, + variable_name: str, value: str, project_name: str = "", + environment_name: str = "") -> bool: + """ + Store a sensitive variable value in GNOME Keyring. + + Args: + project_id: UUID of the project + environment_id: UUID of the environment + variable_name: Name of the variable + value: The secret value to store + project_name: Optional human-readable project name (for display in Seahorse) + environment_name: Optional human-readable environment name (for display) + + Returns: + True if successful, False otherwise + """ + # Create a descriptive label for the keyring UI + label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \ + f"Roster Variable: {variable_name}" + + # Build attributes dictionary for the secret + attributes = { + "project_id": project_id, + "environment_id": environment_id, + "variable_name": variable_name, + } + + try: + # Store the secret synchronously + # Using PASSWORD collection (default keyring) + Secret.password_store_sync( + self.schema, + attributes, + Secret.COLLECTION_DEFAULT, + label, + value, + None # cancellable + ) + logger.info(f"Stored secret for {variable_name} in {environment_id}") + return True + + except GLib.Error as e: + logger.error(f"Failed to store secret: {e.message}") + return False + + def retrieve_secret(self, project_id: str, environment_id: str, + variable_name: str) -> str | None: + """ + Retrieve a sensitive variable value from GNOME Keyring. + + Args: + project_id: UUID of the project + environment_id: UUID of the environment + variable_name: Name of the variable + + Returns: + The secret value if found, None otherwise + """ + attributes = { + "project_id": project_id, + "environment_id": environment_id, + "variable_name": variable_name, + } + + try: + # Retrieve the secret synchronously + value = Secret.password_lookup_sync( + self.schema, + attributes, + None # cancellable + ) + + if value is None: + logger.debug(f"No secret found for {variable_name}") + else: + logger.debug(f"Retrieved secret for {variable_name}") + + return value + + except GLib.Error as e: + logger.error(f"Failed to retrieve secret: {e.message}") + return None + + def delete_secret(self, project_id: str, environment_id: str, + variable_name: str) -> bool: + """ + Delete a sensitive variable value from GNOME Keyring. + + Args: + project_id: UUID of the project + environment_id: UUID of the environment + variable_name: Name of the variable + + Returns: + True if deleted (or didn't exist), False on error + """ + attributes = { + "project_id": project_id, + "environment_id": environment_id, + "variable_name": variable_name, + } + + try: + # Delete the secret synchronously + deleted = Secret.password_clear_sync( + self.schema, + attributes, + None # cancellable + ) + + if deleted: + logger.info(f"Deleted secret for {variable_name}") + else: + logger.debug(f"No secret to delete for {variable_name}") + + return True + + except GLib.Error as e: + logger.error(f"Failed to delete secret: {e.message}") + return False + + def delete_all_project_secrets(self, project_id: str) -> bool: + """ + Delete all secrets for a project (when project is deleted). + + Args: + project_id: UUID of the project + + Returns: + True if successful, False otherwise + """ + attributes = { + "project_id": project_id, + } + + try: + # Delete all matching secrets + deleted = Secret.password_clear_sync( + self.schema, + attributes, + None + ) + + logger.info(f"Deleted all secrets for project {project_id}") + return True + + except GLib.Error as e: + logger.error(f"Failed to delete project secrets: {e.message}") + return False + + def delete_all_environment_secrets(self, project_id: str, environment_id: str) -> bool: + """ + Delete all secrets for an environment (when environment is deleted). + + Args: + project_id: UUID of the project + environment_id: UUID of the environment + + Returns: + True if successful, False otherwise + """ + attributes = { + "project_id": project_id, + "environment_id": environment_id, + } + + try: + deleted = Secret.password_clear_sync( + self.schema, + attributes, + None + ) + + logger.info(f"Deleted all secrets for environment {environment_id}") + return True + + except GLib.Error as e: + logger.error(f"Failed to delete environment secrets: {e.message}") + return False + + def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str) -> bool: + """ + Rename a variable across all environments (updates secret keys). + + This is called when a variable is renamed. We need to: + 1. Find all secrets with old_name + 2. Store them with new_name + 3. Delete old secrets + + Args: + project_id: UUID of the project + old_name: Current variable name + new_name: New variable name + + Returns: + True if successful, False otherwise + """ + # Search for all secrets with the old variable name + attributes = { + "project_id": project_id, + "variable_name": old_name, + } + + try: + # Search for matching secrets + # This returns a list of Secret.Item objects + secrets = Secret.password_search_sync( + self.schema, + attributes, + Secret.SearchFlags.ALL, + None + ) + + if not secrets: + logger.debug(f"No secrets found for variable {old_name}") + return True + + # For each secret, retrieve value, store with new name, delete old + for item in secrets: + # Get the secret value + item.load_secret_sync(None) + value = item.get_secret().get_text() + + # Get environment_id from attributes + attrs = item.get_attributes() + environment_id = attrs.get("environment_id") + + if not environment_id: + logger.warning(f"Secret missing environment_id, skipping") + continue + + # Store with new name + self.store_secret( + project_id=project_id, + environment_id=environment_id, + variable_name=new_name, + value=value + ) + + # Delete old secret + self.delete_secret( + project_id=project_id, + environment_id=environment_id, + variable_name=old_name + ) + + logger.info(f"Renamed variable secrets from {old_name} to {new_name}") + return True + + except GLib.Error as e: + logger.error(f"Failed to rename variable secrets: {e.message}") + return False + + +# Singleton instance +_secret_manager = None + +def get_secret_manager() -> SecretManager: + """Get the global SecretManager instance.""" + global _secret_manager + if _secret_manager is None: + _secret_manager = SecretManager() + return _secret_manager diff --git a/src/widgets/variable-data-row.ui b/src/widgets/variable-data-row.ui index 2aa3ed4..b9db1de 100644 --- a/src/widgets/variable-data-row.ui +++ b/src/widgets/variable-data-row.ui @@ -23,6 +23,17 @@ + + + channel-insecure-symbolic + Mark as sensitive (store in keyring) + + + + + edit-delete-symbolic diff --git a/src/widgets/variable_data_row.py b/src/widgets/variable_data_row.py index bba5a1e..052a65e 100644 --- a/src/widgets/variable_data_row.py +++ b/src/widgets/variable_data_row.py @@ -30,6 +30,7 @@ class VariableDataRow(Gtk.Box): variable_cell = Gtk.Template.Child() name_entry = Gtk.Template.Child() + sensitive_toggle = Gtk.Template.Child() delete_button = Gtk.Template.Child() values_box = Gtk.Template.Child() @@ -37,19 +38,30 @@ class VariableDataRow(Gtk.Box): 'variable-changed': (GObject.SIGNAL_RUN_FIRST, None, ()), 'variable-delete-requested': (GObject.SIGNAL_RUN_FIRST, None, ()), 'value-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), # env_id, value + 'sensitivity-changed': (GObject.SIGNAL_RUN_FIRST, None, (bool,)), # is_sensitive } - def __init__(self, variable_name, environments, size_group, update_timestamp=None): + def __init__(self, variable_name, environments, size_group, project, project_manager, update_timestamp=None): super().__init__() self.variable_name = variable_name self.environments = environments self.size_group = size_group + self.project = project + self.project_manager = project_manager self.value_entries = {} self.update_timeout_id = None + self._updating_toggle = False # Flag to prevent recursion # Set variable name self.name_entry.set_text(variable_name) + # Set initial sensitivity state + is_sensitive = variable_name in self.project.sensitive_variables + self._updating_toggle = True + self.sensitive_toggle.set_active(is_sensitive) + self._update_sensitive_icon(is_sensitive) + self._updating_toggle = False + # Add variable cell to size group for alignment if size_group: size_group.add_widget(self.variable_cell) @@ -68,6 +80,9 @@ class VariableDataRow(Gtk.Box): self.value_entries = {} + # Check if this variable is sensitive + is_sensitive = self.variable_name in self.project.sensitive_variables + # Create entry for each environment for env in self.environments: # Create a box to hold the entry (for size group alignment) @@ -76,8 +91,22 @@ class VariableDataRow(Gtk.Box): entry = Gtk.Entry() entry.set_placeholder_text(env.name) - entry.set_text(env.variables.get(self.variable_name, "")) entry.set_hexpand(True) + + # Get value from appropriate storage (keyring or JSON) + value = self.project_manager.get_variable_value( + self.project.id, + env.id, + self.variable_name + ) + entry.set_text(value) + + # Configure entry for sensitive variables + if is_sensitive: + entry.set_visibility(False) # Show bullets instead of text + entry.set_input_purpose(Gtk.InputPurpose.PASSWORD) + entry.add_css_class("sensitive-variable") + entry.connect('changed', self._on_value_changed, env.id) entry_box.append(entry) @@ -91,7 +120,15 @@ class VariableDataRow(Gtk.Box): def _on_value_changed(self, entry, env_id): """Handle value entry changes.""" - self.emit('value-changed', env_id, entry.get_text()) + value = entry.get_text() + # Use project_manager to store value in appropriate location + self.project_manager.set_variable_value( + self.project.id, + env_id, + self.variable_name, + value + ) + self.emit('value-changed', env_id, value) @Gtk.Template.Callback() def on_name_changed(self, entry): @@ -103,13 +140,45 @@ class VariableDataRow(Gtk.Box): """Handle delete button click.""" self.emit('variable-delete-requested') + @Gtk.Template.Callback() + def on_sensitive_toggled(self, toggle_button): + """Handle sensitivity toggle.""" + if self._updating_toggle: + return + + is_sensitive = toggle_button.get_active() + self._update_sensitive_icon(is_sensitive) + + # Emit signal so parent can update the project + self.emit('sensitivity-changed', is_sensitive) + + def _update_sensitive_icon(self, is_sensitive): + """Update the lock icon based on sensitivity state.""" + if is_sensitive: + self.sensitive_toggle.set_icon_name("channel-secure-symbolic") + self.sensitive_toggle.set_tooltip_text("Sensitive (stored in keyring)\nClick to make non-sensitive") + self.name_entry.add_css_class("sensitive-variable-name") + else: + self.sensitive_toggle.set_icon_name("channel-insecure-symbolic") + self.sensitive_toggle.set_tooltip_text("Not sensitive (stored in JSON)\nClick to mark as sensitive") + self.name_entry.remove_css_class("sensitive-variable-name") + def get_variable_name(self): """Return current variable name.""" return self.name_entry.get_text().strip() - def refresh(self, environments): - """Refresh with updated environments list.""" + def refresh(self, environments, project): + """Refresh with updated environments and project.""" self.environments = environments + self.project = project + + # Update sensitivity toggle state + is_sensitive = self.variable_name in self.project.sensitive_variables + self._updating_toggle = True + self.sensitive_toggle.set_active(is_sensitive) + self._update_sensitive_icon(is_sensitive) + self._updating_toggle = False + self._populate_values() def _apply_update_marking(self, timestamp_str): diff --git a/src/window.py b/src/window.py index d84cca2..7bda58d 100644 --- a/src/window.py +++ b/src/window.py @@ -188,6 +188,16 @@ class RosterWindow(Adw.ApplicationWindow): mix(@success_bg_color, @view_bg_color, 0.35), mix(@success_bg_color, @view_bg_color, 0.15)); } + + /* Sensitive variable styling */ + entry.sensitive-variable { + font-family: monospace; + } + + entry.sensitive-variable-name { + color: @accent_color; + font-weight: 600; + } """) Gtk.StyleContext.add_provider_for_display(