Use XDG Secrets Portal instead of direct D-Bus access

This commit is contained in:
Pavel Baksy 2026-01-19 22:06:10 +01:00
parent 198bb9ad59
commit 0720649036
9 changed files with 640 additions and 378 deletions

View File

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

View File

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

View File

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

View File

@ -169,10 +169,12 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response):
if response == "delete":
self.project_manager.delete_variable(self.project.id, var_name)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def on_delete_complete():
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)
@ -181,10 +183,12 @@ 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:
self.project_manager.rename_variable(self.project.id, old_name, new_name)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def on_rename_complete():
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):
@ -267,10 +271,12 @@ class EnvironmentsDialog(Adw.Dialog):
def on_response(dlg, response):
if response == "delete":
self.project_manager.delete_environment(self.project.id, environment.id)
self._reload_project()
self._populate_table()
self.emit('environments-updated')
def on_delete_complete():
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)
@ -283,22 +289,19 @@ class EnvironmentsDialog(Adw.Dialog):
def _on_sensitivity_changed(self, widget, is_sensitive, var_name):
"""Handle variable sensitivity toggle."""
def on_complete(success):
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)
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')
self.project_manager.mark_variable_as_sensitive(self.project.id, var_name, on_complete)
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')
self.project_manager.mark_variable_as_nonsensitive(self.project.id, var_name, on_complete)
def _reload_project(self):
"""Reload project data from manager."""

View File

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

View File

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

View File

@ -23,6 +23,9 @@ 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
@ -31,8 +34,9 @@ Each secret is identified by:
import gi
gi.require_version('Secret', '1')
from gi.repository import Secret, GLib
from gi.repository import Secret, GLib, Gio
import logging
from typing import Callable, Optional, Any
logger = logging.getLogger(__name__)
@ -50,7 +54,10 @@ ROSTER_SCHEMA = Secret.Schema.new(
class SecretManager:
"""Manages sensitive variable storage using GNOME Keyring via libsecret."""
"""Manages sensitive variable storage using GNOME Keyring via libsecret.
All methods use async APIs for compatibility with the XDG Secrets Portal.
"""
def __init__(self):
"""Initialize the secret manager."""
@ -58,9 +65,10 @@ class SecretManager:
def store_secret(self, project_id: str, environment_id: str,
variable_name: str, value: str, project_name: str = "",
environment_name: str = "") -> bool:
environment_name: str = "",
callback: Optional[Callable[[bool], None]] = None):
"""
Store a sensitive variable value in GNOME Keyring.
Store a sensitive variable value in GNOME Keyring (async).
Args:
project_id: UUID of the project
@ -69,9 +77,7 @@ 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)
Returns:
True if successful, False otherwise
callback: Optional callback function called with success boolean
"""
# Create a descriptive label for the keyring UI
label = f"Roster: {project_name}/{environment_name}/{variable_name}" if project_name else \
@ -84,36 +90,40 @@ class SecretManager:
"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
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)
except GLib.Error as e:
logger.error(f"Failed to store secret: {e.message}")
return False
# Store the secret asynchronously
Secret.password_store(
self.schema,
attributes,
Secret.COLLECTION_DEFAULT,
label,
value,
None, # cancellable
on_store_complete,
None # user_data
)
def retrieve_secret(self, project_id: str, environment_id: str,
variable_name: str) -> str | None:
variable_name: str,
callback: Callable[[Optional[str]], None]):
"""
Retrieve a sensitive variable value from GNOME Keyring.
Retrieve a sensitive variable value from GNOME Keyring (async).
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
callback: Callback function called with the secret value (or None if not found)
"""
attributes = {
"project_id": project_id,
@ -121,37 +131,38 @@ class SecretManager:
"variable_name": variable_name,
}
try:
# Retrieve the secret synchronously
value = Secret.password_lookup_sync(
self.schema,
attributes,
None # cancellable
)
def on_lookup_complete(source, result, user_data):
try:
value = Secret.password_lookup_finish(result)
if value is None:
logger.debug(f"No secret found for {variable_name}")
else:
logger.debug(f"Retrieved secret for {variable_name}")
callback(value)
except GLib.Error as e:
logger.error(f"Failed to retrieve secret: {e.message}")
callback(None)
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
# Retrieve the secret asynchronously
Secret.password_lookup(
self.schema,
attributes,
None, # cancellable
on_lookup_complete,
None # user_data
)
def delete_secret(self, project_id: str, environment_id: str,
variable_name: str) -> bool:
variable_name: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Delete a sensitive variable value from GNOME Keyring.
Delete a sensitive variable value from GNOME Keyring (async).
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
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
@ -159,85 +170,98 @@ class SecretManager:
"variable_name": variable_name,
}
try:
# Delete the secret synchronously
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None # cancellable
)
def on_clear_complete(source, result, user_data):
try:
deleted = Secret.password_clear_finish(result)
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)
except GLib.Error as e:
logger.error(f"Failed to delete secret: {e.message}")
if callback:
callback(False)
if deleted:
logger.info(f"Deleted secret for {variable_name}")
else:
logger.debug(f"No secret to delete for {variable_name}")
# Delete the secret asynchronously
Secret.password_clear(
self.schema,
attributes,
None, # cancellable
on_clear_complete,
None # user_data
)
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:
def delete_all_project_secrets(self, project_id: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Delete all secrets for a project (when project is deleted).
Args:
project_id: UUID of the project
Returns:
True if successful, False otherwise
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
}
try:
# Delete all matching secrets
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None
)
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)
logger.info(f"Deleted all secrets for project {project_id}")
return True
# Delete all matching secrets asynchronously
Secret.password_clear(
self.schema,
attributes,
None,
on_clear_complete,
None
)
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:
def delete_all_environment_secrets(self, project_id: str, environment_id: str,
callback: Optional[Callable[[bool], None]] = None):
"""
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
callback: Optional callback function called with success boolean
"""
attributes = {
"project_id": project_id,
"environment_id": environment_id,
}
try:
deleted = Secret.password_clear_sync(
self.schema,
attributes,
None
)
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)
logger.info(f"Deleted all secrets for environment {environment_id}")
return True
Secret.password_clear(
self.schema,
attributes,
None,
on_clear_complete,
None
)
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:
def rename_variable_secrets(self, project_id: str, old_name: str, new_name: str,
callback: Optional[Callable[[bool], None]] = None):
"""
Rename a variable across all environments (updates secret keys).
@ -250,65 +274,174 @@ class SecretManager:
project_id: UUID of the project
old_name: Current variable name
new_name: New variable name
Returns:
True if successful, False otherwise
callback: Optional callback function called with success boolean
"""
# 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
)
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}")
return True
if not secrets:
logger.debug(f"No secrets found for variable {old_name}")
if callback:
callback(True)
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()
# Track how many secrets we need to process
pending_count = [len(secrets)]
all_success = [True]
# Get environment_id from attributes
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(
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."""
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
value = secret.get_text()
attrs = item.get_attributes()
environment_id = attrs.get("environment_id")
if not environment_id:
logger.warning(f"Secret missing environment_id, skipping")
continue
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)
# Store with new name
self.store_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=new_name,
value=value
value=value,
callback=on_store_done
)
# Delete old secret
self.delete_secret(
project_id=project_id,
environment_id=environment_id,
variable_name=old_name
)
except GLib.Error as e:
logger.error(f"Failed to load secret for rename: {e.message}")
callback(False)
logger.info(f"Renamed variable secrets from {old_name} to {new_name}")
return True
item.load_secret(None, on_load_complete, None)
except GLib.Error as e:
logger.error(f"Failed to rename variable secrets: {e.message}")
return False
# ========== 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
)
# Singleton instance

View File

@ -107,22 +107,12 @@ 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
@ -132,17 +122,34 @@ 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()
# Use project_manager to store value in appropriate location
# Emit signal first (value is being changed)
self.emit('value-changed', env_id, value)
# Use project_manager to store value in appropriate location (async)
self.project_manager.set_variable_value(
self.project.id,
env_id,
self.variable_name,
value
value,
callback=None # Fire and forget - errors are logged
)
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,10 +535,13 @@ class RosterWindow(Adw.ApplicationWindow):
if preprocessing_result.modified_request:
modified_request = preprocessing_result.modified_request
# Apply variable substitution to (possibly modified) request
substituted_request = modified_request
if widget.selected_environment_id:
env = widget.get_selected_environment()
# 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 env:
from .variable_substitution import VariableSubstitution
substituted_request, undefined = VariableSubstitution.substitute_request(modified_request, env)
@ -546,65 +549,60 @@ 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."""
# Re-enable send button
widget.send_button.set_sensitive(True)
# Execute async
def callback(response, error, user_data):
"""Callback runs on main thread."""
# Re-enable send button
widget.send_button.set_sensitive(True)
# Update tab with response
if response:
widget.display_response(response)
# Update tab with response
if response:
widget.display_response(response)
# Execute postprocessing script if exists
scripts = widget.get_scripts()
if scripts and scripts.postprocessing.strip():
from .script_executor import ScriptExecutor, ScriptContext
# Execute postprocessing script if exists
scripts = widget.get_scripts()
if scripts and scripts.postprocessing.strip():
from .script_executor import ScriptExecutor, ScriptContext
# Create context for variable updates
context = None
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
)
# Create context for variable updates
context = None
if widget.project_id and widget.selected_environment_id:
context = ScriptContext(
project_id=widget.project_id,
environment_id=widget.selected_environment_id,
project_manager=self.project_manager
# Execute script with context
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
)
# Execute script with context
script_result = ScriptExecutor.execute_postprocessing_script(
scripts.postprocessing,
response,
context
)
# Display script results
widget.display_script_results(script_result)
# Display script results
widget.display_script_results(script_result)
# Process variable updates
self._process_variable_updates(script_result, widget, context)
# Process variable updates
self._process_variable_updates(script_result, widget, context)
else:
widget._clear_script_results()
else:
widget.display_error(error or "Unknown error")
widget._clear_script_results()
else:
widget.display_error(error or "Unknown error")
widget._clear_script_results()
# Save response to tab
page = self.tab_view.get_selected_page()
if page:
tab = self.page_to_tab.get(page)
if tab:
tab.response = response
# Save response to tab
page = self.tab_view.get_selected_page()
if page:
tab = self.page_to_tab.get(page)
if tab:
tab.response = response
# Create history entry with redacted sensitive variables
# Determine which request to save to history
request_for_history = modified_request
has_redacted = False
# Create history entry with redacted sensitive variables
# Determine which request to save to history
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()
@ -624,22 +622,28 @@ class RosterWindow(Adw.ApplicationWindow):
# No sensitive variables defined, use fully substituted request
request_for_history = substituted_request
elif env:
# Environment selected but no project - use fully substituted request
# Environment loaded but no project - use fully substituted request
request_for_history = substituted_request
entry = HistoryEntry(
timestamp=datetime.now().isoformat(),
request=request_for_history,
response=response,
error=error,
has_redacted_variables=has_redacted
)
self.history_manager.add_entry(entry)
entry = HistoryEntry(
timestamp=datetime.now().isoformat(),
request=request_for_history,
response=response,
error=error,
has_redacted_variables=has_redacted
)
self.history_manager.add_entry(entry)
# Refresh history panel to show new entry
self._load_history()
# Refresh history panel to show new entry
self._load_history()
self.http_client.execute_request_async(substituted_request, callback, None)
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: