Add sensitive variable support with GNOME Keyring integration

This commit is contained in:
Pavel Baksy 2026-01-12 23:28:55 +01:00
parent 3ab8b0c926
commit 55ccdc8a1e
14 changed files with 1379 additions and 28 deletions

View File

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

View File

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

View File

@ -9,7 +9,8 @@
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--socket=wayland"
"--socket=wayland",
"--talk-name=org.freedesktop.secrets"
],
"cleanup" : [
"/include",

158
example_sensitive_vars.py Normal file
View File

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

View File

@ -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', ],
)

View File

@ -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()

View File

@ -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',

View File

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

View File

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

View File

@ -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."""

322
src/secret_manager.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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

View File

@ -23,6 +23,17 @@
</object>
</child>
<child>
<object class="GtkToggleButton" id="sensitive_toggle">
<property name="icon-name">channel-insecure-symbolic</property>
<property name="tooltip-text">Mark as sensitive (store in keyring)</property>
<signal name="toggled" handler="on_sensitive_toggled"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
<child>
<object class="GtkButton" id="delete_button">
<property name="icon-name">edit-delete-symbolic</property>

View File

@ -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):

View File

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