Compare commits

..

No commits in common. "0720649036e2a4a5fafb9940337a23f9fb6c0de6" and "6273b224d5d15a237f394faecdf32e6a0cf2e1dc" have entirely different histories.

9 changed files with 375 additions and 637 deletions

View File

@ -9,7 +9,8 @@
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--socket=wayland"
"--socket=wayland",
"--talk-name=org.freedesktop.secrets"
],
"cleanup": [
"/include",
@ -31,8 +32,11 @@
{
"type": "git",
"url": "https://git.bugsy.cz/beval/roster.git",
"tag": "v0.8.2"
"tag": "v0.7.0"
}
],
"config-opts": [
"--libdir=lib"
]
}
]

View File

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

View File

@ -1,5 +1,5 @@
project('roster',
version: '0.8.2',
version: '0.8.1',
meson_version: '>= 1.0.0',
default_options: [ 'warning_level=2', 'werror=false', ],
)

View File

@ -169,13 +169,11 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response):
if response == "delete":
def on_delete_complete():
self.project_manager.delete_variable(self.project.id, var_name)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.delete_variable(self.project.id, var_name, on_delete_complete)
dialog.connect("response", on_response)
dialog.present(self)
@ -183,13 +181,11 @@ class EnvironmentsDialog(Adw.Dialog):
"""Handle variable name change."""
new_name = row.get_variable_name()
if new_name and new_name != old_name and new_name not in self.project.variable_names:
def on_rename_complete():
self.project_manager.rename_variable(self.project.id, old_name, new_name)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.rename_variable(self.project.id, old_name, new_name, on_rename_complete)
@Gtk.Template.Callback()
def on_add_environment_clicked(self, button):
"""Add new environment."""
@ -271,13 +267,11 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response):
if response == "delete":
def on_delete_complete():
self.project_manager.delete_environment(self.project.id, environment.id)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
self.project_manager.delete_environment(self.project.id, environment.id, on_delete_complete)
dialog.connect("response", on_response)
dialog.present(self)
@ -289,19 +283,22 @@ class EnvironmentsDialog(Adw.Dialog):
def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
"""Handle variable sensitivity toggle."""
def on_complete(success):
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')
if is_sensitive:
# Mark as sensitive (move to keyring)
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
else:
# Mark as non-sensitive (move to JSON)
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
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."""

View File

@ -22,7 +22,7 @@ import json
import uuid
import logging
from pathlib import Path
from typing import List, Optional, Callable
from typing import List, Optional
from datetime import datetime, timezone
import gi
gi.require_version('GLib', '2.0')
@ -132,23 +132,18 @@ class ProjectManager:
break
self.save_projects(projects)
def delete_project(self, project_id: str,
callback: Optional[Callable[[], None]] = None):
"""Delete a project and all its requests (async for secret cleanup)."""
def delete_project(self, project_id: str):
"""Delete a project and all its requests."""
projects = self.load_projects()
secret_manager = get_secret_manager()
# Remove project from list and save immediately
# 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)
# Delete all secrets for this project asynchronously
def on_secrets_deleted(success):
if callback:
callback()
secret_manager.delete_all_project_secrets(project_id, on_secrets_deleted)
def add_request(self, project_id: str, name: str, request: HttpRequest, scripts=None) -> SavedRequest:
"""Add request to a project."""
projects = self.load_projects()
@ -244,27 +239,20 @@ class ProjectManager:
break
self.save_projects(projects)
def delete_environment(self, project_id: str, env_id: str,
callback: Optional[Callable[[], None]] = None):
"""Delete an environment (async for secret cleanup)."""
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)
# Delete all secrets for this environment asynchronously
def on_secrets_deleted(success):
if callback:
callback()
secret_manager.delete_all_environment_secrets(project_id, env_id, on_secrets_deleted)
def add_variable(self, project_id: str, variable_name: str):
"""Add a variable to the project and all environments."""
projects = self.load_projects()
@ -278,12 +266,10 @@ class ProjectManager:
break
self.save_projects(projects)
def rename_variable(self, project_id: str, old_name: str, new_name: str,
callback: Optional[Callable[[], None]] = None):
"""Rename a variable in the project and all environments (async for secrets)."""
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()
is_sensitive = False
for p in projects:
if p.id == project_id:
@ -292,33 +278,24 @@ class ProjectManager:
idx = p.variable_names.index(old_name)
p.variable_names[idx] = new_name
# Check if sensitive and update list
# Update sensitive_variables list if applicable
if old_name in p.sensitive_variables:
is_sensitive = True
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:
env.variables[new_name] = env.variables.pop(old_name)
break
self.save_projects(projects)
# Rename secrets in keyring if sensitive
if is_sensitive:
secret_manager.rename_variable_secrets(project_id, old_name, new_name, lambda success: callback() if callback else None)
elif callback:
callback()
def delete_variable(self, project_id: str, variable_name: str,
callback: Optional[Callable[[], None]] = None):
"""Delete a variable from the project and all environments (async for secrets)."""
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()
is_sensitive = False
environments_to_cleanup = []
for p in projects:
if p.id == project_id:
@ -326,33 +303,19 @@ class ProjectManager:
if variable_name in p.variable_names:
p.variable_names.remove(variable_name)
# Check if sensitive and get environments for cleanup
# Remove from sensitive_variables if applicable
if variable_name in p.sensitive_variables:
is_sensitive = True
p.sensitive_variables.remove(variable_name)
environments_to_cleanup = [env.id for env in p.environments]
# 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)
break
self.save_projects(projects)
# Delete secrets for this variable asynchronously
if is_sensitive and environments_to_cleanup:
pending_count = [len(environments_to_cleanup)]
def on_single_delete(success):
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback()
for env_id in environments_to_cleanup:
secret_manager.delete_secret(project_id, env_id, variable_name, on_single_delete)
elif callback:
callback()
def update_environment_variable(self, project_id: str, env_id: str, variable_name: str, value: str):
"""Update a single variable value in an environment."""
projects = self.load_projects()
@ -388,10 +351,9 @@ class ProjectManager:
# ========== Sensitive Variable Management ==========
def mark_variable_as_sensitive(self, project_id: str, variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
def mark_variable_as_sensitive(self, project_id: str, variable_name: str) -> bool:
"""
Mark a variable as sensitive (move values from JSON to keyring) - async.
Mark a variable as sensitive (move values from JSON to keyring).
This will:
1. Add variable to sensitive_variables list
@ -401,11 +363,12 @@ class ProjectManager:
Args:
project_id: Project ID
variable_name: Variable name to mark as sensitive
callback: Optional callback called with success boolean
Returns:
True if successful
"""
projects = self.load_projects()
secret_manager = get_secret_manager()
secrets_to_store = []
for p in projects:
if p.id == project_id:
@ -413,56 +376,30 @@ class ProjectManager:
if variable_name not in p.sensitive_variables:
p.sensitive_variables.append(variable_name)
# Collect all environment values to store in keyring
# Move all environment values to keyring
for env in p.environments:
if variable_name in env.variables:
value = env.variables[variable_name]
secrets_to_store.append({
'environment_id': env.id,
'environment_name': env.name,
'value': value,
'project_name': p.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
# Store all secrets asynchronously
if not secrets_to_store:
if callback:
callback(True)
return
return False
pending_count = [len(secrets_to_store)]
all_success = [True]
def on_single_store(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
for secret_info in secrets_to_store:
secret_manager.store_secret(
project_id=project_id,
environment_id=secret_info['environment_id'],
variable_name=variable_name,
value=secret_info['value'],
project_name=secret_info['project_name'],
environment_name=secret_info['environment_name'],
callback=on_single_store
)
return
if callback:
callback(False)
def mark_variable_as_nonsensitive(self, project_id: str, variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
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) - async.
Mark a variable as non-sensitive (move values from keyring to JSON).
This will:
1. Remove variable from sensitive_variables list
@ -472,62 +409,40 @@ class ProjectManager:
Args:
project_id: Project ID
variable_name: Variable name to mark as non-sensitive
callback: Optional callback called with success boolean
Returns:
True if successful
"""
projects = self.load_projects()
secret_manager = get_secret_manager()
target_project = None
environments_to_migrate = []
for p in projects:
if p.id == project_id:
target_project = p
# Remove from sensitive list
if variable_name in p.sensitive_variables:
p.sensitive_variables.remove(variable_name)
environments_to_migrate = list(p.environments)
break
if not target_project or not environments_to_migrate:
if callback:
callback(False)
return
# Retrieve all secrets, then update JSON, then delete from keyring
pending_count = [len(environments_to_migrate)]
all_success = [True]
def on_single_migrate(env):
def on_retrieve(value):
# 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 ""
self.save_projects(projects)
# Delete from keyring
def on_delete(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
secret_manager.delete_secret(
project_id=project_id,
environment_id=env.id,
variable_name=variable_name,
callback=on_delete
variable_name=variable_name
)
return on_retrieve
for env in environments_to_migrate:
secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env.id,
variable_name=variable_name,
callback=on_single_migrate(env)
)
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."""
@ -537,10 +452,9 @@ class ProjectManager:
return variable_name in p.sensitive_variables
return False
def get_variable_value(self, project_id: str, env_id: str, variable_name: str,
callback: Callable[[str], None]):
def get_variable_value(self, project_id: str, env_id: str, variable_name: str) -> str:
"""
Get variable value from appropriate storage (keyring or JSON) - async.
Get variable value from appropriate storage (keyring or JSON).
This is a convenience method that handles both sensitive and non-sensitive variables.
@ -548,7 +462,9 @@ class ProjectManager:
project_id: Project ID
env_id: Environment ID
variable_name: Variable name
callback: Callback called with variable value (empty string if not found)
Returns:
Variable value (empty string if not found)
"""
projects = self.load_projects()
@ -556,32 +472,25 @@ class ProjectManager:
if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables:
# Retrieve from keyring asynchronously
# Retrieve from keyring
secret_manager = get_secret_manager()
def on_retrieve(value):
callback(value or "")
secret_manager.retrieve_secret(
value = secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env_id,
variable_name=variable_name,
callback=on_retrieve
variable_name=variable_name
)
return
return value or ""
else:
# Retrieve from JSON synchronously
# Retrieve from JSON
for env in p.environments:
if env.id == env_id:
callback(env.variables.get(variable_name, ""))
return
return env.variables.get(variable_name, "")
callback("")
return ""
def set_variable_value(self, project_id: str, env_id: str, variable_name: str, value: str,
callback: Optional[Callable[[bool], None]] = None):
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) - async.
Set variable value in appropriate storage (keyring or JSON).
This is a convenience method that handles both sensitive and non-sensitive variables.
@ -590,7 +499,6 @@ class ProjectManager:
env_id: Environment ID
variable_name: Variable name
value: Value to set
callback: Optional callback called with success boolean
"""
projects = self.load_projects()
secret_manager = get_secret_manager()
@ -599,7 +507,7 @@ class ProjectManager:
if p.id == project_id:
# Check if sensitive
if variable_name in p.sensitive_variables:
# Store in keyring asynchronously
# Store in keyring
for env in p.environments:
if env.id == env_id:
secret_manager.store_secret(
@ -608,32 +516,24 @@ class ProjectManager:
variable_name=variable_name,
value=value,
project_name=p.name,
environment_name=env.name,
callback=callback
environment_name=env.name
)
# Keep empty placeholder in JSON
env.variables[variable_name] = ""
self.save_projects(projects)
return
break
else:
# Store in JSON synchronously
# Store in JSON
for env in p.environments:
if env.id == env_id:
env.variables[variable_name] = value
break
self.save_projects(projects)
if callback:
callback(True)
return
if callback:
callback(False)
def get_environment_with_secrets(self, project_id: str, env_id: str,
callback: Callable[[Optional[Environment]], None]):
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) - async.
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.
@ -641,7 +541,9 @@ class ProjectManager:
Args:
project_id: Project ID
env_id: Environment ID
callback: Callback called with Environment object (or None if not found)
Returns:
Environment object with all values, or None if not found
"""
projects = self.load_projects()
secret_manager = get_secret_manager()
@ -658,24 +560,16 @@ class ProjectManager:
created_at=env.created_at
)
# If no sensitive variables, return immediately
if not p.sensitive_variables:
callback(complete_env)
return
# Fill in sensitive variable values from keyring
def on_secrets_retrieved(secrets_dict):
for var_name, value in secrets_dict.items():
if value is not None:
complete_env.variables[var_name] = value
callback(complete_env)
secret_manager.retrieve_multiple_secrets(
for var_name in p.sensitive_variables:
value = secret_manager.retrieve_secret(
project_id=project_id,
environment_id=env_id,
variable_names=list(p.sensitive_variables),
callback=on_secrets_retrieved
variable_name=var_name
)
return
if value is not None:
complete_env.variables[var_name] = value
callback(None)
return complete_env
return None

View File

@ -1318,38 +1318,31 @@ class RequestTabWidget(Gtk.Box):
# Always update visual indicators when environment changes
self._update_variable_indicators()
def get_selected_environment(self, callback):
def get_selected_environment(self):
"""
Get the currently selected environment object with all values (async).
Get the currently selected environment object with all values.
This includes sensitive variable values from the keyring.
Args:
callback: Function called with the Environment object (or None)
"""
if not self.selected_environment_id or not self.project_manager or not self.project_id:
callback(None)
return
return None
# Get environment with secrets (includes values from keyring) - async
self.project_manager.get_environment_with_secrets(
# Get environment with secrets (includes values from keyring)
return self.project_manager.get_environment_with_secrets(
self.project_id,
self.selected_environment_id,
callback
self.selected_environment_id
)
def _detect_undefined_variables(self, callback):
"""Detect undefined variables in the current request (async).
Args:
callback: Function called with set of undefined variable names
"""
def _detect_undefined_variables(self):
"""Detect undefined variables in the current request. Returns set of undefined variable names."""
from .variable_substitution import VariableSubstitution
# Get current request from UI
request = self.get_request()
def on_environment_loaded(env):
# Get selected environment
env = self.get_selected_environment()
if not env:
# No environment selected - ALL variables are undefined
# Find all variables in the request
@ -1359,14 +1352,11 @@ class RequestTabWidget(Gtk.Box):
for key, value in request.headers.items():
all_vars.update(VariableSubstitution.find_variables(key))
all_vars.update(VariableSubstitution.find_variables(value))
callback(all_vars)
return all_vars
else:
# Environment selected - find which variables are undefined
_, undefined = VariableSubstitution.substitute_request(request, env)
callback(undefined)
# Get selected environment asynchronously
self.get_selected_environment(on_environment_loaded)
return undefined
def _schedule_indicator_update(self):
"""Schedule an indicator update with debouncing."""
@ -1384,14 +1374,13 @@ class RequestTabWidget(Gtk.Box):
return False # Don't repeat
def _update_variable_indicators(self):
"""Update visual indicators for undefined variables (async)."""
"""Update visual indicators for undefined variables."""
# Only update if we have a project (variables only make sense in project context)
if not self.project_id:
return
def on_undefined_detected(undefined_vars):
# Store detected undefined variables
self.undefined_variables = undefined_vars
# Detect undefined variables
self.undefined_variables = self._detect_undefined_variables()
# Update URL entry
self._update_url_indicator()
@ -1402,9 +1391,6 @@ class RequestTabWidget(Gtk.Box):
# Update body
self._update_body_indicators()
# Detect undefined variables asynchronously
self._detect_undefined_variables(on_undefined_detected)
def _update_url_indicator(self):
"""Update warning indicator on URL entry."""
from .variable_substitution import VariableSubstitution

View File

@ -23,9 +23,6 @@ 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).
Uses async APIs for compatibility with the XDG Secrets Portal in sandboxed
environments (Flatpak).
Each secret is identified by:
- project_id: UUID of the project
- environment_id: UUID of the environment
@ -34,9 +31,8 @@ Each secret is identified by:
import gi
gi.require_version('Secret', '1')
from gi.repository import Secret, GLib, Gio
from gi.repository import Secret, GLib
import logging
from typing import Callable, Optional, Any
logger = logging.getLogger(__name__)
@ -54,10 +50,7 @@ ROSTER_SCHEMA = Secret.Schema.new(
class SecretManager:
"""Manages sensitive variable storage using GNOME Keyring via libsecret.
All methods use async APIs for compatibility with the XDG Secrets Portal.
"""
"""Manages sensitive variable storage using GNOME Keyring via libsecret."""
def __init__(self):
"""Initialize the secret manager."""
@ -65,10 +58,9 @@ class SecretManager:
def store_secret(self, project_id: str, environment_id: str,
variable_name: str, value: str, project_name: str = "",
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
environment_name: str = "") -> bool:
"""
Store a sensitive variable value in GNOME Keyring (async).
Store a sensitive variable value in GNOME Keyring.
Args:
project_id: UUID of the project
@ -77,7 +69,9 @@ class SecretManager:
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)
callback: Optional callback function called with success boolean
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 \
@ -90,40 +84,36 @@ class SecretManager:
"variable_name": variable_name,
}
def on_store_complete(source, result, user_data):
try:
Secret.password_store_finish(result)
logger.info(f"Stored secret for {variable_name} in {environment_id}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to store secret: {e.message}")
if callback:
callback(False)
# Store the secret asynchronously
Secret.password_store(
# Store the secret synchronously
# Using PASSWORD collection (default keyring)
Secret.password_store_sync(
self.schema,
attributes,
Secret.COLLECTION_DEFAULT,
label,
value,
None, # cancellable
on_store_complete,
None # user_data
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,
callback: Callable[[Optional[str]], None]):
variable_name: str) -> str | None:
"""
Retrieve a sensitive variable value from GNOME Keyring (async).
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
callback: Callback function called with the secret value (or None if not found)
Returns:
The secret value if found, None otherwise
"""
attributes = {
"project_id": project_id,
@ -131,38 +121,37 @@ class SecretManager:
"variable_name": variable_name,
}
def on_lookup_complete(source, result, user_data):
try:
value = Secret.password_lookup_finish(result)
# 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}")
callback(value)
return value
except GLib.Error as e:
logger.error(f"Failed to retrieve secret: {e.message}")
callback(None)
# Retrieve the secret asynchronously
Secret.password_lookup(
self.schema,
attributes,
None, # cancellable
on_lookup_complete,
None # user_data
)
return None
def delete_secret(self, project_id: str, environment_id: str,
variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
variable_name: str) -> bool:
"""
Delete a sensitive variable value from GNOME Keyring (async).
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
callback: Optional callback function called with success boolean
Returns:
True if deleted (or didn't exist), False on error
"""
attributes = {
"project_id": project_id,
@ -170,98 +159,85 @@ class SecretManager:
"variable_name": variable_name,
}
def on_clear_complete(source, result, user_data):
try:
deleted = Secret.password_clear_finish(result)
# 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}")
if callback:
callback(True)
return True
except GLib.Error as e:
logger.error(f"Failed to delete secret: {e.message}")
if callback:
callback(False)
return False
# Delete the secret asynchronously
Secret.password_clear(
self.schema,
attributes,
None, # cancellable
on_clear_complete,
None # user_data
)
def delete_all_project_secrets(self, project_id: str,
callback: Optional[Callable[[bool], None]] = None):
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
callback: Optional callback function called with success boolean
Returns:
True if successful, False otherwise
"""
attributes = {
"project_id": project_id,
}
def on_clear_complete(source, result, user_data):
try:
Secret.password_clear_finish(result)
logger.info(f"Deleted all secrets for project {project_id}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to delete project secrets: {e.message}")
if callback:
callback(False)
# Delete all matching secrets asynchronously
Secret.password_clear(
# Delete all matching secrets
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None,
on_clear_complete,
None
)
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
callback: Optional[Callable[[bool], None]] = 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
callback: Optional callback function called with success boolean
Returns:
True if successful, False otherwise
"""
attributes = {
"project_id": project_id,
"environment_id": environment_id,
}
def on_clear_complete(source, result, user_data):
try:
Secret.password_clear_finish(result)
logger.info(f"Deleted all secrets for environment {environment_id}")
if callback:
callback(True)
except GLib.Error as e:
logger.error(f"Failed to delete environment secrets: {e.message}")
if callback:
callback(False)
Secret.password_clear(
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None,
on_clear_complete,
None
)
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
callback: Optional[Callable[[bool], None]] = 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).
@ -274,174 +250,65 @@ class SecretManager:
project_id: UUID of the project
old_name: Current variable name
new_name: New variable name
callback: Optional callback function called with success boolean
Returns:
True if successful, False otherwise
"""
# Search for all secrets with the old variable name
attributes = {
"project_id": project_id,
"variable_name": old_name,
}
def on_search_complete(source, result, user_data):
try:
secrets = Secret.password_search_finish(result)
if not secrets:
logger.debug(f"No secrets found for variable {old_name}")
if callback:
callback(True)
return
# Track how many secrets we need to process
pending_count = [len(secrets)]
all_success = [True]
def on_rename_done(success):
pending_count[0] -= 1
if not success:
all_success[0] = False
if pending_count[0] == 0:
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
if callback:
callback(all_success[0])
# For each secret, retrieve value, store with new name, delete old
for item in secrets:
self._rename_single_secret(item, project_id, old_name, new_name, on_rename_done)
except GLib.Error as e:
logger.error(f"Failed to search for variable secrets: {e.message}")
if callback:
callback(False)
# Search for matching secrets asynchronously
Secret.password_search(
# Search for matching secrets
# This returns a list of Secret.Item objects
secrets = Secret.password_search_sync(
self.schema,
attributes,
Secret.SearchFlags.ALL,
None,
on_search_complete,
None
)
def _rename_single_secret(self, item, project_id: str, old_name: str,
new_name: str, callback: Callable[[bool], None]):
"""Helper to rename a single secret item."""
if not secrets:
logger.debug(f"No secrets found for variable {old_name}")
return True
def on_load_complete(item, result, user_data):
try:
item.load_secret_finish(result)
secret = item.get_secret()
if secret is None:
callback(False)
return
# 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()
value = secret.get_text()
# Get environment_id from attributes
attrs = item.get_attributes()
environment_id = attrs.get("environment_id")
if not environment_id:
logger.warning("Secret missing environment_id, skipping")
callback(False)
return
# Store with new name, then delete old
def on_store_done(success):
if success:
self.delete_secret(project_id, environment_id, old_name, callback)
else:
callback(False)
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,
callback=on_store_done
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 load secret for rename: {e.message}")
callback(False)
item.load_secret(None, on_load_complete, None)
# ========== Batch Operations ==========
def retrieve_multiple_secrets(self, project_id: str, environment_id: str,
variable_names: list,
callback: Callable[[dict], None]):
"""
Retrieve multiple secrets at once (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variable_names: List of variable names to retrieve
callback: Callback function called with dict of {variable_name: value}
"""
if not variable_names:
callback({})
return
results = {}
pending_count = [len(variable_names)]
def on_single_retrieve(var_name):
def handler(value):
results[var_name] = value
pending_count[0] -= 1
if pending_count[0] == 0:
callback(results)
return handler
for var_name in variable_names:
self.retrieve_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=var_name,
callback=on_single_retrieve(var_name)
)
def store_multiple_secrets(self, project_id: str, environment_id: str,
variables: dict, project_name: str = "",
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
"""
Store multiple secrets at once (async).
Args:
project_id: UUID of the project
environment_id: UUID of the environment
variables: Dict of {variable_name: value} to store
project_name: Optional project name for display
environment_name: Optional environment name for display
callback: Optional callback called with overall success boolean
"""
if not variables:
if callback:
callback(True)
return
pending_count = [len(variables)]
all_success = [True]
def on_single_store(success):
if not success:
all_success[0] = False
pending_count[0] -= 1
if pending_count[0] == 0 and callback:
callback(all_success[0])
for var_name, value in variables.items():
self.store_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=var_name,
value=value,
project_name=project_name,
environment_name=environment_name,
callback=on_single_store
)
logger.error(f"Failed to rename variable secrets: {e.message}")
return False
# Singleton instance

View File

@ -107,12 +107,22 @@ class VariableDataRow(Gtk.Box):
entry.set_placeholder_text(env.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)
# Add entry box to size group for alignment
@ -122,34 +132,17 @@ class VariableDataRow(Gtk.Box):
self.values_box.append(entry_box)
self.value_entries[env.id] = entry
# Get value from appropriate storage (keyring or JSON) asynchronously
def on_value_retrieved(value, entry=entry, env_id=env.id):
entry.set_text(value)
# Connect changed handler after setting initial value to avoid spurious signals
entry.connect('changed', self._on_value_changed, env_id)
self.project_manager.get_variable_value(
self.project.id,
env.id,
self.variable_name,
on_value_retrieved
)
def _on_value_changed(self, entry, env_id):
"""Handle value entry changes."""
value = entry.get_text()
# Emit signal first (value is being changed)
self.emit('value-changed', env_id, value)
# Use project_manager to store value in appropriate location (async)
# Use project_manager to store value in appropriate location
self.project_manager.set_variable_value(
self.project.id,
env_id,
self.variable_name,
value,
callback=None # Fire and forget - errors are logged
value
)
self.emit('value-changed', env_id, value)
def _on_name_entry_focus_in(self, controller):
"""Show edit buttons when entry gains focus."""

View File

@ -535,13 +535,10 @@ class RosterWindow(Adw.ApplicationWindow):
if preprocessing_result.modified_request:
modified_request = preprocessing_result.modified_request
# Disable send button during request
widget.send_button.set_sensitive(False)
def proceed_with_request(env):
"""Continue with request after environment is loaded."""
# Apply variable substitution to (possibly modified) request
substituted_request = modified_request
if widget.selected_environment_id:
env = widget.get_selected_environment()
if env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
@ -549,6 +546,9 @@ class RosterWindow(Adw.ApplicationWindow):
if undefined:
logger.warning(f"Undefined variables in request: {', '.join(undefined)}")
# Disable send button during request
widget.send_button.set_sensitive(False)
# Execute async
def callback(response, error, user_data):
"""Callback runs on main thread."""
@ -603,6 +603,8 @@ class RosterWindow(Adw.ApplicationWindow):
request_for_history = modified_request
has_redacted = False
if widget.selected_environment_id:
env = widget.get_selected_environment()
if env and widget.project_id:
# Get the project's sensitive variables list
projects = self.project_manager.load_projects()
@ -622,7 +624,7 @@ class RosterWindow(Adw.ApplicationWindow):
# No sensitive variables defined, use fully substituted request
request_for_history = substituted_request
elif env:
# Environment loaded but no project - use fully substituted request
# Environment selected but no project - use fully substituted request
request_for_history = substituted_request
entry = HistoryEntry(
@ -639,12 +641,6 @@ class RosterWindow(Adw.ApplicationWindow):
self.http_client.execute_request_async(substituted_request, callback, None)
# Fetch environment asynchronously then proceed
if widget.selected_environment_id:
widget.get_selected_environment(proceed_with_request)
else:
proceed_with_request(None)
# History and Project Management
def _load_history(self) -> None:
"""Load history from file and populate list."""