Add sensitive variable support with GNOME Keyring integration
This commit is contained in:
parent
3ab8b0c926
commit
55ccdc8a1e
230
SENSITIVE_VARIABLES_UI_GUIDE.md
Normal file
230
SENSITIVE_VARIABLES_UI_GUIDE.md
Normal 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
|
||||
271
SENSITIVE_VARIABLES_USAGE.md
Normal file
271
SENSITIVE_VARIABLES_USAGE.md
Normal 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
|
||||
@ -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
158
example_sensitive_vars.py
Normal 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)
|
||||
@ -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', ],
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
322
src/secret_manager.py
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user