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 @@
+
+
+
+