First commit

This commit is contained in:
2025-12-19 12:58:58 +01:00
parent cba66667f3
commit 87865e2c6d
26 changed files with 3343 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
# Virtual Environment
venv/
env/
ENV/
.venv/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Distribution / packaging
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Logs
logs/
*.log
# Unit test / coverage
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Azure credentials cache
.azure/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment variables
.env
.env.local
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# Temporary files
*.bak
*.tmp
temp/
tmp/
+263
View File
@@ -0,0 +1,263 @@
# Azure Key Vault Secret Manager
> A modern, user-friendly GUI application for managing Azure App Registration secrets and Key Vault integration.
![Python](https://img.shields.io/badge/python-3.8+-blue.svg)
![License](https://img.shields.io/badge/license-MIT-green.svg)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey.svg)
## ✨ Features
- 🔐 **Single Sign-On**: Interactive browser authentication - login once for both Microsoft Graph and Azure
- 🎯 **Auto-Detection**: Automatically detects your Azure tenant ID from logged-in account
- 📋 **Subscription Selection**: Choose your subscription from a dropdown (no more config files!)
- 🔍 **Smart Dropdowns**: Searchable, scrollable lists with keyboard navigation (Arrow keys, Page Up/Down, Home/End)
- 💡 **Tooltips**: Hover over items to see full names if truncated
- 🔑 **Secret Management**: Generate 50-year secrets with custom descriptions
- 🗑️ **Cleanup**: Optionally remove old secrets when creating new ones
- 💾 **Key Vault Integration**: Automatic storage with metadata tags
- 📋 **Copy to Clipboard**: One-click secret copying
- 🎨 **Modern UI**: Clean interface built with CustomTkinter (supports dark/light themes)
-**Smooth Performance**: Optimized scrolling and no nested scroll lag
## 📸 Screenshots
<!-- Add your screenshots here -->
```
[App Selection] [Secret Generation] [Result View]
```
## 🔧 Prerequisites
- **Python 3.8+** (Python 3.11 recommended)
- **Azure Permissions**:
- Application.ReadWrite.All (Microsoft Graph API)
- Directory.Read.All (Microsoft Graph API)
- Key Vault Secrets Officer role on target Key Vaults
- Reader role on subscription/resource groups
**Note**: No need to create an App Registration! The app uses the Azure CLI public client ID for authentication.
## 🎨 Customization
### Adding a Custom Icon
To replace the default Python icon with your own:
1. Create an icon file (`.ico` for Windows or `.png` for cross-platform)
2. Place it in one of these locations:
- `python-app/icon.ico` or `python-app/icon.png`
- `python-app/assets/icon.ico` or `python-app/assets/icon.png`
3. The application will automatically detect and use it on next launch
**Recommended icon size**: 256x256 pixels
## 📦 Installation
### 1. Clone the Repository
```bash
git clone https://github.com/yourusername/azure-keyvault-manager.git
cd azure-keyvault-manager/python-app
```
### 2. Create Virtual Environment
**Windows:**
```bash
python -m venv venv
venv\Scripts\activate
```
**Linux/macOS:**
```bash
python3 -m venv venv
source venv/bin/activate
```
### 3. Install Dependencies
```bash
pip install -r requirements.txt
```
### 4. Run the Application
```bash
python main.py
```
**That's it!** No configuration files to edit - the app auto-detects everything.
## 🚀 Usage
### Quick Start Guide
1. **Connect to Azure**
- Click **"Connect to Azure"**
- Browser opens automatically
- Sign in with your Azure account (admin credentials)
- ✅ Authentication completes (single login!)
2. **Select Subscription**
- Choose your Azure subscription from the dropdown
- Apps and Key Vaults load automatically
3. **Select App Registration**
- Click the App Registration dropdown
- Scroll through the list or use keyboard navigation:
- `↑` `↓` Arrow keys to navigate
- `Page Up` `Page Down` to jump
- `Home` `End` for first/last
- `Enter` to select
- `Esc` to close
- Hover for tooltips on long names
4. **Generate Secret**
- Enter a description (e.g., "Production API Key 2025")
- Select a Key Vault
- *(Optional)* Check "Remove old secrets"
- Click **"Generate Secret"**
5. **Copy & Save**
- Secret is displayed once
- Click **"Copy to Clipboard"**
- Secret is automatically stored in Key Vault with metadata
- Click **"Generate Another Secret"** to continue
### Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `↓` `↑` | Navigate dropdown items |
| `Page Down` `Page Up` | Jump 5 items |
| `Home` `End` | First/Last item |
| `Enter` | Select item |
| `Escape` | Close dropdown |
| `Mouse Wheel` | Scroll in dropdown |
## 📁 Project Structure
```
python-app/
├── main.py # Application entry point
├── config.py # App settings (no secrets!)
├── requirements.txt # Python dependencies
├── auth/
│ ├── graph_authenticator.py # Microsoft Graph authentication
│ └── azure_authenticator.py # Azure Resource Manager authentication
├── services/
│ ├── app_registration_service.py # App registration operations
│ ├── secret_service.py # Secret generation/management
│ └── keyvault_service.py # Key Vault operations
├── ui/
│ ├── components/
│ │ ├── unified_dropdown.py # Custom dropdown component
│ │ └── tooltip.py # Tooltip utility
│ ├── main_window.py # Main application window
│ ├── login_frame.py # Authentication UI
│ ├── subscription_selection_frame.py
│ ├── app_selection_frame.py # App selection UI
│ ├── secret_generation_frame.py # Secret generation form
│ └── result_frame.py # Result display
└── utils/
├── sanitizer.py # Name sanitization
└── logger.py # Logging setup
```
## 🐛 Troubleshooting
### Authentication Issues
**Problem**: "Authentication failed"
- **Solution**: Ensure you have the required permissions in Azure AD
- Clear cached credentials: Delete `.azure` folder in your home directory
- Verify your account has access to the Azure subscription
**Problem**: Double login prompts
- **Solution**: This has been fixed in the latest version - you should only login once
### Permission Errors
**Problem**: "Failed to list applications"
- **Solution**: Request `Application.ReadWrite.All` and `Directory.Read.All` permissions from your Azure AD admin
**Problem**: "Failed to store secret in Key Vault"
- **Solution**: Ensure you have **Key Vault Secrets Officer** role on the target vault
- Check Key Vault network settings allow your IP address
### UI Issues
**Problem**: Dropdown list won't scroll
- **Solution**: Updated in latest version - mouse wheel now scrolls the dropdown properly
**Problem**: Can't see all applications
- **Solution**: Use keyboard navigation (arrow keys) or mouse wheel to scroll through large lists
### General Issues
**Problem**: No subscriptions found
- **Solution**: Verify your account has at least Reader access to one Azure subscription
**Problem**: No Key Vaults appear
- **Solution**: Create a Key Vault in your subscription or request access to existing ones
## 📝 Logs
Application logs are stored in: `logs/app_YYYYMMDD.log`
Log levels:
- **INFO**: Normal operations
- **ERROR**: Failed operations with stack traces
## 🔒 Security Best Practices
- ✅ Secrets are **only displayed once** in the UI
- ✅ Secrets are **never logged** to files
- ✅ Authentication uses Azure Identity library (secure token caching)
- ✅ Uses Azure CLI public client ID (no app registration needed)
- ⚠️ **Always copy secrets immediately** - they cannot be retrieved later
- ⚠️ Store secrets in a secure password manager after generation
## 🏗️ Building Executable (Optional)
Create a standalone executable:
```bash
pip install pyinstaller
pyinstaller --onefile --windowed --name AzureKeyVaultManager main.py
```
Output: `dist/AzureKeyVaultManager.exe` (Windows) or `dist/AzureKeyVaultManager` (Linux/macOS)
**Note**: Executable size will be ~50-100MB due to bundled dependencies.
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- Built with [CustomTkinter](https://github.com/TomSchimansky/CustomTkinter) by Tom Schimansky
- Uses [Azure SDK for Python](https://github.com/Azure/azure-sdk-for-python)
- Uses [Microsoft Graph SDK for Python](https://github.com/microsoftgraph/msgraph-sdk-python)
## 📮 Support
For issues, questions, or suggestions:
- 🐛 [Open an issue](https://github.com/yourusername/azure-keyvault-manager/issues)
- 💬 [Start a discussion](https://github.com/yourusername/azure-keyvault-manager/discussions)
---
**Made with ❤️ for Azure administrators**
View File
+149
View File
@@ -0,0 +1,149 @@
"""
Azure Authentication Module
Handles authentication to Azure services for Key Vault management.
"""
from azure.identity import InteractiveBrowserCredential
from azure.mgmt.resource import SubscriptionClient
from typing import Optional, List, Dict
class AzureAuthenticator:
"""Handles Azure authentication for Key Vault and resource management."""
def __init__(self):
"""
Initialize the Azure authenticator.
"""
self.tenant_id: Optional[str] = None
self.subscription_id: Optional[str] = None
self.credential: Optional[InteractiveBrowserCredential] = None
self.subscriptions: List[Dict[str, str]] = []
def authenticate(self, credential: InteractiveBrowserCredential = None) -> bool:
"""
Authenticate to Azure. Can reuse credential from Graph authenticator.
Args:
credential: Optional credential to reuse (from GraphAuthenticator)
Returns:
bool: True if authentication succeeded, False otherwise
Raises:
Exception: If authentication fails
"""
try:
if credential:
# Reuse credential from Graph authentication
self.credential = credential
else:
# Create new interactive browser credential with "organizations" tenant
# This allows the user to login with any organizational account
# additionally_allowed_tenants="*" allows acquiring tokens for any tenant
self.credential = InteractiveBrowserCredential(
tenant_id="organizations",
additionally_allowed_tenants=["*"]
)
# List all subscriptions (this will use the cached token from Graph auth)
sub_client = SubscriptionClient(self.credential)
subscriptions_list = list(sub_client.subscriptions.list())
# Extract tenant ID from the first subscription
if subscriptions_list:
# Get tenant ID from subscription (format: /subscriptions/{sub-id})
# The tenant info is in the subscription object
first_sub = subscriptions_list[0]
self.tenant_id = first_sub.tenant_id if hasattr(first_sub, 'tenant_id') else None
if self.tenant_id:
print(f"Detected Tenant ID: {self.tenant_id}")
if subscriptions_list:
print(f"Successfully authenticated to Azure. Found {len(subscriptions_list)} subscription(s).")
# Store subscriptions for later selection
self.subscriptions = [
{
'id': sub.subscription_id,
'name': sub.display_name
}
for sub in subscriptions_list
]
return True
return False
except Exception as e:
raise Exception(f"Azure authentication failed: {str(e)}")
def get_credential(self) -> InteractiveBrowserCredential:
"""
Get the credential object.
Returns:
InteractiveBrowserCredential: The credential object
Raises:
Exception: If not authenticated
"""
if not self.credential:
raise Exception("Not authenticated. Call authenticate() first.")
return self.credential
def get_subscriptions(self) -> List[Dict[str, str]]:
"""
Get the list of available subscriptions.
Returns:
List[Dict]: List of subscriptions with 'id' and 'name'
"""
return self.subscriptions
def set_subscription(self, subscription_id: str):
"""
Set the active subscription ID.
Args:
subscription_id: The subscription ID to use
"""
self.subscription_id = subscription_id
def get_subscription_id(self) -> str:
"""
Get the subscription ID.
Returns:
str: The subscription ID
Raises:
Exception: If subscription not set
"""
if not self.subscription_id:
raise Exception("Subscription not set. Call set_subscription() first.")
return self.subscription_id
def get_tenant_id(self) -> str:
"""
Get the tenant ID.
Returns:
str: The tenant ID
Raises:
Exception: If not authenticated
"""
if not self.tenant_id:
raise Exception("Tenant ID not available. Call authenticate() first.")
return self.tenant_id
def is_authenticated(self) -> bool:
"""
Check if currently authenticated.
Returns:
bool: True if authenticated, False otherwise
"""
return self.credential is not None
+105
View File
@@ -0,0 +1,105 @@
"""
Microsoft Graph Authentication Module
Handles authentication to Microsoft Graph API for app registration management.
"""
from azure.identity import InteractiveBrowserCredential
from msgraph import GraphServiceClient
from typing import Optional
import config
class GraphAuthenticator:
"""Handles Microsoft Graph authentication and client creation."""
def __init__(self, client_id: str = None):
"""
Initialize the Graph authenticator.
Args:
client_id: Application Client ID (defaults to config.CLIENT_ID)
"""
self.client_id = client_id or config.CLIENT_ID
self.credential: Optional[InteractiveBrowserCredential] = None
self.client: Optional[GraphServiceClient] = None
async def authenticate(self) -> bool:
"""
Authenticate to Microsoft Graph using interactive browser login.
Returns:
bool: True if authentication succeeded, False otherwise
Raises:
Exception: If authentication fails
"""
try:
# Create interactive browser credential
# Using "organizations" allows login with any organizational account
# additionally_allowed_tenants="*" allows acquiring tokens for any tenant (needed for Key Vault access)
self.credential = InteractiveBrowserCredential(
tenant_id="organizations",
client_id=self.client_id,
additionally_allowed_tenants=["*"]
)
# Define scopes for Microsoft Graph
scopes = ['https://graph.microsoft.com/.default']
# Create Graph service client
self.client = GraphServiceClient(
credentials=self.credential,
scopes=scopes
)
# Authenticate to Graph API FIRST
# This triggers the initial browser auth for Graph scope
# Then Management API will use SSO (single sign-on) from this auth
me = await self.client.me.get()
if me:
print(f"Successfully authenticated as: {me.display_name} ({me.user_principal_name})")
return True
return False
except Exception as e:
raise Exception(f"Graph authentication failed: {str(e)}")
def get_client(self) -> GraphServiceClient:
"""
Get the authenticated Graph service client.
Returns:
GraphServiceClient: The authenticated client
Raises:
Exception: If not authenticated
"""
if not self.client:
raise Exception("Not authenticated. Call authenticate() first.")
return self.client
def get_credential(self) -> InteractiveBrowserCredential:
"""
Get the credential object for reuse in other Azure services.
Returns:
InteractiveBrowserCredential: The credential object
Raises:
Exception: If not authenticated
"""
if not self.credential:
raise Exception("Not authenticated. Call authenticate() first.")
return self.credential
def is_authenticated(self) -> bool:
"""
Check if currently authenticated.
Returns:
bool: True if authenticated, False otherwise
"""
return self.client is not None and self.credential is not None
+21
View File
@@ -0,0 +1,21 @@
"""
Configuration file for Azure Key Vault Secret Manager
This application automatically detects your tenant ID and allows you to
select your subscription after authentication. No manual configuration needed!
"""
# Azure CLI Client ID (public client for interactive browser auth)
# This is the well-known Azure CLI client ID and does not need to be changed
CLIENT_ID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
# Application Settings
APP_SECRET_EXPIRATION_YEARS = 50
# Required Microsoft Graph API Permissions (Delegated):
# - Application.ReadWrite.All
# - Directory.Read.All
#
# Required Azure RBAC Roles:
# - Key Vault Secrets Officer (or Contributor) on target Key Vaults
# - Reader on subscription/resource groups
+316
View File
@@ -0,0 +1,316 @@
"""
Azure Key Vault Secret Manager
Main application entry point.
"""
import asyncio
import customtkinter as ctk
from tkinter import messagebox
import sys
# Import modules
import config
from auth.graph_authenticator import GraphAuthenticator
from auth.azure_authenticator import AzureAuthenticator
from services.app_registration_service import AppRegistrationService
from services.secret_service import SecretService
from services.keyvault_service import KeyVaultService
from ui.main_window import MainWindow
from utils.logger import setup_logger
# Set appearance
ctk.set_appearance_mode("system")
ctk.set_default_color_theme("blue")
class Application:
"""Main application class."""
def __init__(self):
"""Initialize the application."""
self.logger = setup_logger()
# Initialize async worker FIRST (before any async operations)
from utils.async_worker import AsyncWorker
self.async_worker = AsyncWorker()
self.async_worker.start()
# Authentication
self.graph_auth = GraphAuthenticator()
self.azure_auth = AzureAuthenticator()
# Services (will be initialized after authentication)
self.app_service = None
self.secret_service = None
self.vault_service = None
# Data
self.apps = []
self.vaults = []
self.subscriptions = []
self.selected_app = None
self.selected_subscription = None
# Create main window
self.window = MainWindow(
on_connect=self.handle_connect,
on_subscription_selected=self.handle_subscription_selected,
on_app_selected=self.handle_app_selected,
on_generate_secret=self.handle_generate_secret,
on_generate_another=self.handle_generate_another
)
def handle_connect(self):
"""Handle authentication button click."""
self.window.set_connecting()
# Submit to async worker instead of creating new loop
def on_complete(future):
"""Handle authentication completion in main thread."""
try:
future.result() # Raises if authentication failed
except Exception as e:
self.logger.error(f"Authentication failed: {str(e)}")
self.window.after(0, lambda: messagebox.showerror(
"Authentication Error",
f"Failed to authenticate:\n\n{str(e)}\n\nPlease try again."
))
self.window.after(0, self.window.enable_connect_button)
future = self.async_worker.submit(self._authenticate())
future.add_done_callback(on_complete)
async def _authenticate(self):
"""Perform authentication."""
try:
self.logger.info("Starting authentication...")
# Authenticate to Microsoft Graph
await self.graph_auth.authenticate()
# Authenticate to Azure (reuse credential)
credential = self.graph_auth.get_credential()
self.azure_auth.authenticate(credential)
# Initialize Graph services
graph_client = self.graph_auth.get_client()
self.app_service = AppRegistrationService(graph_client)
self.secret_service = SecretService(graph_client)
# Update UI
self.window.set_authenticated(True)
self.logger.info("Authentication successful")
# Load subscriptions (should use SSO from Graph auth above)
await self._load_subscriptions()
except Exception as e:
self.logger.error(f"Authentication failed: {str(e)}")
messagebox.showerror(
"Authentication Error",
f"Failed to authenticate:\n\n{str(e)}\n\nPlease try again."
)
self.window.enable_connect_button()
async def _load_subscriptions(self):
"""Load Azure subscriptions."""
try:
self.window.set_loading_subscriptions(True)
self.logger.info("Loading subscriptions...")
# Get subscriptions from authenticator
self.subscriptions = self.azure_auth.get_subscriptions()
self.window.set_subscriptions(self.subscriptions)
self.window.set_loading_subscriptions(False)
self.logger.info(f"Loaded {len(self.subscriptions)} subscription(s)")
except Exception as e:
self.logger.error(f"Failed to load subscriptions: {str(e)}")
messagebox.showerror(
"Load Error",
f"Failed to load subscriptions:\n\n{str(e)}"
)
def handle_subscription_selected(self, subscription):
"""Handle subscription selection."""
self.selected_subscription = subscription
self.logger.info(f"Selected subscription: {subscription['name']} ({subscription['id']})")
# Set the subscription in the azure authenticator
self.azure_auth.set_subscription(subscription['id'])
# Initialize KeyVault service with the selected subscription
self.vault_service = KeyVaultService(
self.azure_auth.get_credential(),
self.azure_auth.get_subscription_id()
)
# Enable UI elements
self.window.set_subscription_selected()
# Submit data loading to async worker (no threading.Thread needed)
def on_complete(future):
"""Handle data loading completion."""
try:
future.result()
except Exception as e:
self.logger.error(f"Failed to load data: {str(e)}")
self.window.after(0, lambda: messagebox.showerror(
"Load Error",
f"Failed to load data:\n\n{str(e)}"
))
future = self.async_worker.submit(self._load_data())
future.add_done_callback(on_complete)
async def _load_data(self):
"""Load app registrations and Key Vaults."""
try:
# Load apps
self.window.set_loading_apps(True)
self.logger.info("Loading app registrations...")
self.apps = await self.app_service.list_applications()
self.window.set_apps(self.apps)
self.window.set_loading_apps(False)
self.logger.info(f"Loaded {len(self.apps)} app registrations")
# Load Key Vaults
self.window.set_loading_vaults(True)
self.logger.info("Loading Key Vaults...")
self.vaults = self.vault_service.list_keyvaults()
self.window.set_vaults(self.vaults)
self.window.set_loading_vaults(False)
self.logger.info(f"Loaded {len(self.vaults)} Key Vaults")
except Exception as e:
self.logger.error(f"Failed to load data: {str(e)}")
messagebox.showerror(
"Load Error",
f"Failed to load data:\n\n{str(e)}"
)
def handle_app_selected(self, app):
"""Handle app selection."""
self.selected_app = app
self.logger.info(f"Selected app: {app['display_name']}")
def handle_generate_secret(self, description: str, vault: dict, remove_old: bool):
"""Handle secret generation."""
# Submit to async worker instead of creating new loop
def on_complete(future):
"""Handle generation completion."""
try:
future.result()
except Exception as e:
self.logger.error(f"Failed to generate secret: {str(e)}")
self.window.after(0, lambda: self.window.set_generating(False))
self.window.after(0, lambda: messagebox.showerror(
"Generation Error",
f"Failed to generate secret:\n\n{str(e)}"
))
future = self.async_worker.submit(self._generate_secret(description, vault, remove_old))
future.add_done_callback(on_complete)
async def _generate_secret(self, description: str, vault: dict, remove_old: bool):
"""Generate secret asynchronously."""
try:
# Validate inputs
app = self.window.get_selected_app()
if not app:
messagebox.showwarning("Validation Error", "Please select an app registration.")
return
if not description:
messagebox.showwarning("Validation Error", "Please enter a secret description.")
return
if not vault:
messagebox.showwarning("Validation Error", "Please select a Key Vault.")
return
self.window.set_generating(True)
self.logger.info(f"Generating secret for app: {app['display_name']}")
# Create secret
secret_result = await self.secret_service.create_secret(
app_object_id=app['id'],
description=description
)
self.logger.info(f"Secret created successfully. Key ID: {secret_result['key_id']}")
# Remove old secrets if requested
removed_count = 0
if remove_old:
self.logger.info("Removing old secrets...")
removed_count = await self.secret_service.remove_old_secrets(
app_object_id=app['id'],
keep_key_id=secret_result['key_id']
)
self.logger.info(f"Removed {removed_count} old secret(s)")
# Store in Key Vault
self.logger.info(f"Storing secret in Key Vault: {vault['name']}")
sanitized_name = self.vault_service.store_secret(
vault_name=vault['name'],
secret_name=app['display_name'],
secret_value=secret_result['secret_text'],
description=description,
secret_id=secret_result['key_id'],
expires=secret_result['end_datetime']
)
self.logger.info(f"Secret stored successfully: {sanitized_name}")
# Show result in the main window (no popup needed - result frame shows all info)
self.window.show_result(
secret_name=sanitized_name,
vault_name=vault['name'],
secret_value=secret_result['secret_text'],
removed_count=removed_count
)
# Reset generating state
self.window.set_generating(False)
except Exception as e:
self.logger.error(f"Failed to generate secret: {str(e)}")
self.window.set_generating(False)
messagebox.showerror(
"Generation Error",
f"Failed to generate secret:\n\n{str(e)}"
)
def handle_generate_another(self):
"""Handle generate another secret."""
self.window.reset_form()
self.logger.info("Form reset for another secret generation")
def cleanup(self):
"""Cleanup resources on application exit."""
self.logger.info("Shutting down application...")
if hasattr(self, 'async_worker'):
self.async_worker.stop()
def _on_closing(self):
"""Handle window close event."""
self.cleanup()
self.window.destroy()
def run(self):
"""Run the application."""
self.logger.info("Starting Azure Key Vault Secret Manager")
# Register cleanup on window close
self.window.protocol("WM_DELETE_WINDOW", self._on_closing)
self.window.mainloop()
if __name__ == "__main__":
app = Application()
app.run()
+19
View File
@@ -0,0 +1,19 @@
# GUI Framework
customtkinter==5.2.2
pillow==10.3.0
# Azure Authentication
azure-identity==1.16.0
# Microsoft Graph
msgraph-sdk==1.3.0
# Azure Key Vault
azure-keyvault-secrets==4.8.0
azure-mgmt-keyvault==10.3.0
# Azure Management
azure-mgmt-resource==23.1.1
# Utilities
python-dateutil==2.9.0
View File
+99
View File
@@ -0,0 +1,99 @@
"""
App Registration Service
Handles operations related to Azure AD app registrations.
"""
from msgraph import GraphServiceClient
from typing import List, Dict
class AppRegistrationService:
"""Service for managing app registrations via Microsoft Graph."""
def __init__(self, graph_client: GraphServiceClient):
"""
Initialize the app registration service.
Args:
graph_client: Authenticated Graph service client
"""
self.graph_client = graph_client
async def list_applications(self) -> List[Dict[str, str]]:
"""
Get all app registrations from Azure AD.
Returns:
List[Dict]: List of app registrations with id, app_id, and display_name
Raises:
Exception: If the API call fails
"""
try:
apps = []
# Get all applications
result = await self.graph_client.applications.get()
if result and result.value:
for app in result.value:
apps.append({
'id': app.id, # Object ID
'app_id': app.app_id, # Application (client) ID
'display_name': app.display_name
})
# Handle pagination if there are more than 100 apps
while result and result.odata_next_link:
# Continue fetching next page
from kiota_abstractions.base_request_configuration import RequestConfiguration
request_config = RequestConfiguration()
request_config.url = result.odata_next_link
result = await self.graph_client.applications.get(request_configuration=request_config)
if result and result.value:
for app in result.value:
apps.append({
'id': app.id,
'app_id': app.app_id,
'display_name': app.display_name
})
# Sort alphabetically by display name
apps.sort(key=lambda x: x['display_name'].lower())
return apps
except Exception as e:
raise Exception(f"Failed to list applications: {str(e)}")
async def get_application(self, app_object_id: str) -> Dict[str, any]:
"""
Get a specific app registration by its object ID.
Args:
app_object_id: The object ID of the app registration
Returns:
Dict: App registration details
Raises:
Exception: If the API call fails
"""
try:
app = await self.graph_client.applications.by_application_id(app_object_id).get()
if app:
return {
'id': app.id,
'app_id': app.app_id,
'display_name': app.display_name,
'password_credentials': app.password_credentials
}
return None
except Exception as e:
raise Exception(f"Failed to get application: {str(e)}")
+187
View File
@@ -0,0 +1,187 @@
"""
Key Vault Service
Handles operations related to Azure Key Vault.
"""
from azure.identity import InteractiveBrowserCredential
from azure.keyvault.secrets import SecretClient
from azure.mgmt.keyvault import KeyVaultManagementClient
from datetime import datetime
from typing import List, Dict
import re
class KeyVaultService:
"""Service for managing Key Vault operations."""
def __init__(self, credential: InteractiveBrowserCredential, subscription_id: str):
"""
Initialize the Key Vault service.
Args:
credential: Authenticated credential
subscription_id: Azure subscription ID
"""
self.credential = credential
self.subscription_id = subscription_id
self.mgmt_client = KeyVaultManagementClient(credential, subscription_id)
def list_keyvaults(self, resource_group: str = None) -> List[Dict[str, str]]:
"""
List all Key Vaults in the subscription.
Args:
resource_group: Optional resource group to filter by
Returns:
List[Dict]: List of Key Vaults with name, id, location, and resource_group
Raises:
Exception: If the API call fails
"""
try:
vaults = []
if resource_group:
# Get vaults from specific resource group
vault_list = self.mgmt_client.vaults.list_by_resource_group(resource_group)
else:
# Get all vaults in subscription
vault_list = self.mgmt_client.vaults.list_by_subscription()
for vault in vault_list:
# Extract resource group from vault ID
# Format: /subscriptions/{sub-id}/resourceGroups/{rg-name}/providers/...
rg_name = vault.id.split('/')[4] if len(vault.id.split('/')) > 4 else ''
vaults.append({
'name': vault.name,
'id': vault.id,
'location': vault.location,
'resource_group': rg_name
})
# Sort by name
vaults.sort(key=lambda x: x['name'].lower())
return vaults
except Exception as e:
raise Exception(f"Failed to list Key Vaults: {str(e)}")
def store_secret(
self,
vault_name: str,
secret_name: str,
secret_value: str,
description: str,
secret_id: str,
expires: datetime
) -> str:
"""
Store a secret in Key Vault with tags.
Args:
vault_name: Name of the Key Vault
secret_name: Name for the secret (will be sanitized)
secret_value: The secret value to store
description: Description tag
secret_id: Secret ID tag (from app registration)
expires: Expiration date
Returns:
str: The sanitized secret name
Raises:
Exception: If the operation fails
"""
try:
# Sanitize the secret name
sanitized_name = self._sanitize_name(secret_name)
# Construct vault URL
vault_url = f"https://{vault_name}.vault.azure.net"
# Create secret client
secret_client = SecretClient(vault_url=vault_url, credential=self.credential)
# Create tags
tags = {
'Description': description,
'SecretId': secret_id
}
# Set the secret
secret = secret_client.set_secret(
name=sanitized_name,
value=secret_value,
tags=tags,
expires_on=expires
)
return secret.name
except Exception as e:
raise Exception(f"Failed to store secret in Key Vault: {str(e)}")
def _sanitize_name(self, name: str) -> str:
"""
Sanitize a name for use in Key Vault.
Key Vault secret names can only contain alphanumeric characters and hyphens.
Args:
name: The name to sanitize
Returns:
str: Sanitized name
"""
if not name:
return name
# Replace any non-alphanumeric character (except hyphens) with hyphen
sanitized = re.sub(r'[^0-9a-zA-Z-]', '-', name)
# Remove consecutive hyphens
sanitized = re.sub(r'-+', '-', sanitized)
# Remove leading/trailing hyphens
sanitized = sanitized.strip('-')
return sanitized
def get_secret(self, vault_name: str, secret_name: str) -> Dict[str, any]:
"""
Retrieve a secret from Key Vault.
Args:
vault_name: Name of the Key Vault
secret_name: Name of the secret
Returns:
Dict: Secret properties including value and tags
Raises:
Exception: If the operation fails
"""
try:
# Construct vault URL
vault_url = f"https://{vault_name}.vault.azure.net"
# Create secret client
secret_client = SecretClient(vault_url=vault_url, credential=self.credential)
# Get the secret
secret = secret_client.get_secret(secret_name)
return {
'name': secret.name,
'value': secret.value,
'tags': secret.properties.tags,
'expires_on': secret.properties.expires_on,
'created_on': secret.properties.created_on
}
except Exception as e:
raise Exception(f"Failed to retrieve secret from Key Vault: {str(e)}")
+155
View File
@@ -0,0 +1,155 @@
"""
Secret Service
Handles creation and removal of app registration secrets.
"""
from msgraph import GraphServiceClient
from msgraph.generated.models.password_credential import PasswordCredential
from msgraph.generated.applications.item.add_password.add_password_post_request_body import AddPasswordPostRequestBody
from msgraph.generated.applications.item.remove_password.remove_password_post_request_body import RemovePasswordPostRequestBody
from datetime import datetime, timedelta
from typing import Dict, List, Any
import config
class SecretService:
"""Service for managing app registration secrets."""
def __init__(self, graph_client: GraphServiceClient):
"""
Initialize the secret service.
Args:
graph_client: Authenticated Graph service client
"""
self.graph_client = graph_client
async def create_secret(
self,
app_object_id: str,
description: str,
years: int = None
) -> Dict[str, Any]:
"""
Create a new secret for an app registration.
Args:
app_object_id: The object ID of the app registration
description: Display name/description for the secret
years: Number of years until expiration (defaults to config.APP_SECRET_EXPIRATION_YEARS)
Returns:
Dict: Contains secret_text, key_id, and end_datetime
Raises:
Exception: If the API call fails
"""
try:
if years is None:
years = config.APP_SECRET_EXPIRATION_YEARS
# Calculate expiration date
end_date = datetime.now() + timedelta(days=365 * years)
# Create password credential
password_cred = PasswordCredential()
password_cred.display_name = description
password_cred.end_date_time = end_date
# Create request body
request_body = AddPasswordPostRequestBody()
request_body.password_credential = password_cred
# Call Graph API to add password
result = await self.graph_client.applications.by_application_id(
app_object_id
).add_password.post(request_body)
if result:
return {
'secret_text': result.secret_text,
'key_id': str(result.key_id),
'end_datetime': result.end_date_time,
'display_name': result.display_name
}
raise Exception("No result returned from add_password")
except Exception as e:
raise Exception(f"Failed to create secret: {str(e)}")
async def remove_old_secrets(
self,
app_object_id: str,
keep_key_id: str
) -> int:
"""
Remove all secrets except the specified one.
Args:
app_object_id: The object ID of the app registration
keep_key_id: The key ID to keep (newly created secret)
Returns:
int: Number of secrets removed
Raises:
Exception: If the API call fails
"""
try:
removed_count = 0
# Get the app with its password credentials
app = await self.graph_client.applications.by_application_id(app_object_id).get()
if app and app.password_credentials:
for cred in app.password_credentials:
# Remove if it's not the one we want to keep
if str(cred.key_id) != str(keep_key_id):
# Create request body
request_body = RemovePasswordPostRequestBody()
request_body.key_id = cred.key_id
# Call Graph API to remove password
await self.graph_client.applications.by_application_id(
app_object_id
).remove_password.post(request_body)
removed_count += 1
return removed_count
except Exception as e:
raise Exception(f"Failed to remove old secrets: {str(e)}")
async def list_secrets(self, app_object_id: str) -> List[Dict[str, Any]]:
"""
List all secrets for an app registration (metadata only, not the secret values).
Args:
app_object_id: The object ID of the app registration
Returns:
List[Dict]: List of secret metadata
Raises:
Exception: If the API call fails
"""
try:
app = await self.graph_client.applications.by_application_id(app_object_id).get()
secrets = []
if app and app.password_credentials:
for cred in app.password_credentials:
secrets.append({
'key_id': str(cred.key_id),
'display_name': cred.display_name,
'start_datetime': cred.start_date_time,
'end_datetime': cred.end_date_time
})
return secrets
except Exception as e:
raise Exception(f"Failed to list secrets: {str(e)}")
View File
+89
View File
@@ -0,0 +1,89 @@
"""
App Selection Frame
UI component for selecting an app registration.
"""
import customtkinter as ctk
from typing import List, Dict, Callable, Optional
from ui.components import UnifiedDropdown
class AppSelectionFrame(ctk.CTkFrame):
"""Frame for app registration selection."""
def __init__(self, parent, on_app_selected: Callable = None):
"""
Initialize the app selection frame.
Args:
parent: Parent widget
on_app_selected: Callback function when an app is selected
"""
super().__init__(parent)
self.on_app_selected = on_app_selected
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Create unified dropdown
self.dropdown = UnifiedDropdown(
self,
title="App Registration Selection",
on_selection_changed=self._on_app_selected,
show_count=True,
display_key='display_name',
max_dropdown_height=400
)
self.dropdown.grid(row=0, column=0, sticky="ew")
self.dropdown.set_placeholder("Please connect to Azure first")
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_apps(self, apps: List[Dict[str, str]]):
"""
Set the list of applications.
Args:
apps: List of app dictionaries with 'id', 'app_id', and 'display_name'
"""
self.dropdown.set_items(apps)
def _on_app_selected(self, app: Dict):
"""
Handle app selection.
Args:
app: The selected app dictionary
"""
if self.on_app_selected:
self.on_app_selected(app)
def get_selected_app(self) -> Optional[Dict[str, str]]:
"""
Get the currently selected app.
Returns:
Dict: Selected app or None
"""
return self.dropdown.get_selected()
def set_enabled(self, enabled: bool):
"""
Enable or disable the frame.
Args:
enabled: Whether to enable the frame
"""
self.dropdown.set_enabled(enabled)
def set_loading(self, loading: bool):
"""
Set loading state.
Args:
loading: Whether currently loading
"""
self.dropdown.set_loading(loading)
+10
View File
@@ -0,0 +1,10 @@
"""
UI Components Package
Custom reusable UI components for the application.
"""
from .unified_dropdown import UnifiedDropdown
from .tooltip import ToolTip
__all__ = ['UnifiedDropdown', 'ToolTip']
+139
View File
@@ -0,0 +1,139 @@
"""
ToolTip Component
Lightweight tooltip widget that shows on hover with configurable delay.
"""
import tkinter as tk
from typing import Optional
class ToolTip:
"""
Creates a tooltip for a given widget.
Shows full text on hover if the displayed text is truncated.
"""
def __init__(
self,
widget: tk.Widget,
text: str,
delay: int = 500,
wrap_length: int = 300
):
"""
Initialize the tooltip.
Args:
widget: The widget to attach the tooltip to
text: The text to display in the tooltip
delay: Delay in milliseconds before showing tooltip
wrap_length: Maximum width in pixels before wrapping text
"""
self.widget = widget
self.text = text
self.delay = delay
self.wrap_length = wrap_length
self.tooltip_window: Optional[tk.Toplevel] = None
self.after_id: Optional[str] = None
# Bind hover events
self.widget.bind("<Enter>", self._on_enter, add="+")
self.widget.bind("<Leave>", self._on_leave, add="+")
self.widget.bind("<ButtonPress>", self._on_leave, add="+")
def _on_enter(self, event=None):
"""Handle mouse enter event."""
# Schedule tooltip to appear after delay
self._cancel_scheduled()
self.after_id = self.widget.after(self.delay, self._show_tooltip)
def _on_leave(self, event=None):
"""Handle mouse leave event."""
self._cancel_scheduled()
self._hide_tooltip()
def _cancel_scheduled(self):
"""Cancel scheduled tooltip appearance."""
if self.after_id:
self.widget.after_cancel(self.after_id)
self.after_id = None
def _show_tooltip(self):
"""Display the tooltip window."""
if self.tooltip_window or not self.text:
return
# Get widget position
x = self.widget.winfo_rootx() + 20
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
# Create tooltip window
self.tooltip_window = tk.Toplevel(self.widget)
self.tooltip_window.wm_overrideredirect(True) # Remove window decorations
# Handle screen edge cases
screen_width = self.widget.winfo_screenwidth()
screen_height = self.widget.winfo_screenheight()
# Create label with text
label = tk.Label(
self.tooltip_window,
text=self.text,
justify=tk.LEFT,
background="#ffffe0", # Light yellow background
foreground="#000000", # Black text
relief=tk.SOLID,
borderwidth=1,
wraplength=self.wrap_length,
font=("TkDefaultFont", 9),
padx=8,
pady=6
)
label.pack()
# Update to get actual size
self.tooltip_window.update_idletasks()
tooltip_width = self.tooltip_window.winfo_width()
tooltip_height = self.tooltip_window.winfo_height()
# Adjust position if near screen edges
if x + tooltip_width > screen_width:
x = screen_width - tooltip_width - 10
if y + tooltip_height > screen_height:
y = self.widget.winfo_rooty() - tooltip_height - 5
# Position the tooltip
self.tooltip_window.wm_geometry(f"+{x}+{y}")
def _hide_tooltip(self):
"""Hide the tooltip window."""
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
def update_text(self, text: str):
"""
Update the tooltip text.
Args:
text: New text to display
"""
self.text = text
if self.tooltip_window:
self._hide_tooltip()
def destroy(self):
"""Clean up the tooltip."""
self._cancel_scheduled()
self._hide_tooltip()
# Unbind events
try:
self.widget.unbind("<Enter>")
self.widget.unbind("<Leave>")
self.widget.unbind("<ButtonPress>")
except:
pass
+535
View File
@@ -0,0 +1,535 @@
"""
Unified Dropdown Component
Custom dropdown widget with popup list, keyboard navigation, and tooltips.
"""
import customtkinter as ctk
import tkinter as tk
from typing import List, Dict, Callable, Optional, Any
from .tooltip import ToolTip
class UnifiedDropdown(ctk.CTkFrame):
"""
Unified dropdown component with popup list.
Provides consistent selection UI across the application with:
- Popup list that expands below button
- Scrollable list for large datasets
- Keyboard navigation (arrows, PageUp/Down, Home/End)
- Tooltips on hover
- Automatic positioning
"""
def __init__(
self,
parent,
title: str = "",
on_selection_changed: Callable = None,
show_count: bool = True,
display_key: str = "display_name",
display_format: Callable = None,
max_dropdown_height: int = 400,
button_width: int = 600,
button_height: int = 40
):
"""
Initialize the unified dropdown.
Args:
parent: Parent widget
title: Title label text
on_selection_changed: Callback when selection changes
show_count: Whether to show item count label
display_key: Key to use for display text from item dict
display_format: Optional formatter function for display text
max_dropdown_height: Maximum height of dropdown popup
button_width: Width of the dropdown button
button_height: Height of the dropdown button
"""
super().__init__(parent)
self.title = title
self.on_selection_changed = on_selection_changed
self.show_count = show_count
self.display_key = display_key
self.display_format = display_format
self.max_dropdown_height = max_dropdown_height
self.button_width = button_width
self.button_height = button_height
# State
self.items: List[Dict] = []
self.selected_item: Optional[Dict] = None
self.current_index: int = -1
self.item_buttons: List[ctk.CTkButton] = []
self.item_tooltips: List[ToolTip] = []
# Popup window
self.popup_window: Optional[tk.Toplevel] = None
self.popup_frame: Optional[ctk.CTkScrollableFrame] = None
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Build UI
self._build_ui()
def _build_ui(self):
"""Build the dropdown UI."""
# Title label (only show if title is not empty)
if self.title:
self.title_label = ctk.CTkLabel(
self,
text=self.title,
font=ctk.CTkFont(size=18, weight="bold")
)
self.title_label.grid(row=0, column=0, padx=20, pady=(20, 5), sticky="w")
else:
self.title_label = None
# Count label (optional)
if self.show_count:
self.count_label = ctk.CTkLabel(
self,
text="0 items found",
font=ctk.CTkFont(size=12),
text_color="gray"
)
self.count_label.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="w")
# Dropdown button
self.dropdown_button = ctk.CTkButton(
self,
text="Please connect to Azure first",
command=self._toggle_dropdown,
width=self.button_width,
height=self.button_height,
font=ctk.CTkFont(size=14),
anchor="w",
state="disabled"
)
row_offset = 2 if self.show_count else 1
self.dropdown_button.grid(row=row_offset, column=0, padx=20, pady=(0, 20), sticky="ew")
# Bind keyboard events to button
self.dropdown_button.bind("<Down>", lambda e: self._open_dropdown())
self.dropdown_button.bind("<Up>", lambda e: self._open_dropdown())
self.dropdown_button.bind("<space>", lambda e: self._toggle_dropdown())
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_items(self, items: List[Dict]):
"""
Set the list of items.
Args:
items: List of item dictionaries
"""
self.items = items
self.current_index = -1
# Update count label
if self.show_count:
count_text = f"{len(items)} item{'s' if len(items) != 1 else ''} found"
self.count_label.configure(text=count_text)
# Enable/disable button
if items:
self.dropdown_button.configure(state="normal")
# Auto-select first item
if items:
self._select_item(0, trigger_callback=False)
else:
self.dropdown_button.configure(
state="disabled",
text="No items found"
)
self.selected_item = None
def _get_display_text(self, item: Dict) -> str:
"""
Get display text for an item.
Args:
item: Item dictionary
Returns:
Display text string
"""
if self.display_format:
return self.display_format(item)
elif self.display_key in item:
return str(item[self.display_key])
elif 'name' in item:
return str(item['name'])
elif 'display_name' in item:
return str(item['display_name'])
else:
return str(item)
def _toggle_dropdown(self):
"""Toggle dropdown popup visibility."""
if self.popup_window:
self._close_dropdown()
else:
self._open_dropdown()
def _open_dropdown(self):
"""Open the dropdown popup."""
if self.popup_window or not self.items:
return
# Create popup window
self.popup_window = tk.Toplevel(self)
self.popup_window.wm_overrideredirect(True) # Remove window decorations
self.popup_window.wm_attributes("-topmost", True) # Always on top
# Calculate dynamic height based on number of items
# Each item: 40px button + 4px padding = 44px per item
item_height = 44
calculated_height = len(self.items) * item_height + 10 # +10 for padding
# Use calculated height, but cap at max_dropdown_height
popup_height = min(calculated_height, self.max_dropdown_height)
# Create scrollable frame for items
self.popup_frame = ctk.CTkScrollableFrame(
self.popup_window,
width=self.button_width - 20,
height=popup_height
)
self.popup_frame.pack(fill="both", expand=True)
# Configure scroll speed to match main window (40px per scroll unit = 2x faster)
self.popup_frame._parent_canvas.configure(yscrollincrement=40)
# Clear previous items
self.item_buttons.clear()
for tooltip in self.item_tooltips:
tooltip.destroy()
self.item_tooltips.clear()
# Create button for each item
for idx, item in enumerate(self.items):
display_text = self._get_display_text(item)
# Truncate long text
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
btn = ctk.CTkButton(
self.popup_frame,
text=truncated,
command=lambda i=idx: self._select_item(i, close_popup=True),
font=ctk.CTkFont(size=14),
height=40,
fg_color="transparent",
border_width=1,
border_color="gray50",
hover_color=("gray70", "gray30"),
anchor="w"
)
btn.grid(row=idx, column=0, padx=5, pady=2, sticky="ew")
self.popup_frame.grid_columnconfigure(0, weight=1)
# Add tooltip if text was truncated
if len(display_text) > max_chars:
tooltip = ToolTip(btn, display_text, delay=500)
self.item_tooltips.append(tooltip)
self.item_buttons.append(btn)
# Bind mouse wheel to popup (prevent scrolling main window)
def popup_scroll(event):
"""Handle mouse wheel in popup."""
if event.delta > 0:
self.popup_frame._parent_canvas.yview_scroll(-1, "units")
else:
self.popup_frame._parent_canvas.yview_scroll(1, "units")
return "break" # Stop event propagation
# Bind to both popup window and frame to capture all events
self.popup_window.bind("<MouseWheel>", popup_scroll, add="+")
self.popup_frame.bind("<MouseWheel>", popup_scroll, add="+")
self.popup_frame._parent_canvas.bind("<MouseWheel>", popup_scroll, add="+")
# Bind to all item buttons so scrolling works when hovering over them
for btn in self.item_buttons:
btn.bind("<MouseWheel>", popup_scroll, add="+")
# Highlight currently selected item
if self.current_index >= 0:
self._highlight_item(self.current_index)
# Position popup
self._position_popup()
# Bind keyboard events (use correct Tkinter key names)
self.popup_window.bind("<Escape>", lambda e: self._close_dropdown())
self.popup_window.bind("<Down>", self._navigate_down)
self.popup_window.bind("<Up>", self._navigate_up)
self.popup_window.bind("<Next>", self._navigate_page_down) # PageDown in Tkinter
self.popup_window.bind("<Prior>", self._navigate_page_up) # PageUp in Tkinter
self.popup_window.bind("<Home>", self._navigate_home)
self.popup_window.bind("<End>", self._navigate_end)
self.popup_window.bind("<Return>", self._confirm_selection)
self.popup_window.bind("<FocusOut>", lambda e: self._close_dropdown())
# Set focus to popup
self.popup_window.focus_set()
# Bind click outside to close
self.popup_window.bind("<Button-1>", self._check_click_outside, add="+")
def _close_dropdown(self):
"""Close the dropdown popup."""
if self.popup_window:
# Clean up tooltips
for tooltip in self.item_tooltips:
tooltip.destroy()
self.item_tooltips.clear()
# Unbind mouse wheel from popup widgets
try:
self.popup_window.unbind("<MouseWheel>")
if self.popup_frame:
self.popup_frame.unbind("<MouseWheel>")
self.popup_frame._parent_canvas.unbind("<MouseWheel>")
# Unbind from buttons
for btn in self.item_buttons:
btn.unbind("<MouseWheel>")
except:
pass
# Destroy popup
self.popup_window.destroy()
self.popup_window = None
self.popup_frame = None
self.item_buttons.clear()
def _position_popup(self):
"""Position the popup window below the button."""
# Update to get accurate dimensions
self.popup_window.update_idletasks()
# Get button position
x = self.dropdown_button.winfo_rootx()
y = self.dropdown_button.winfo_rooty() + self.dropdown_button.winfo_height()
# Get screen dimensions
screen_width = self.winfo_screenwidth()
screen_height = self.winfo_screenheight()
# Get popup dimensions
popup_width = self.popup_window.winfo_width()
popup_height = self.popup_window.winfo_height()
# Adjust if near screen edges
if x + popup_width > screen_width:
x = screen_width - popup_width - 10
# Position above if not enough space below
if y + popup_height > screen_height:
y = self.dropdown_button.winfo_rooty() - popup_height
self.popup_window.wm_geometry(f"+{x}+{y}")
def _check_click_outside(self, event):
"""Check if click was outside popup and close if so."""
if self.popup_window:
x, y = event.x_root, event.y_root
popup_x = self.popup_window.winfo_rootx()
popup_y = self.popup_window.winfo_rooty()
popup_width = self.popup_window.winfo_width()
popup_height = self.popup_window.winfo_height()
if not (popup_x <= x <= popup_x + popup_width and
popup_y <= y <= popup_y + popup_height):
self._close_dropdown()
def _select_item(self, index: int, trigger_callback: bool = True, close_popup: bool = False):
"""
Select an item by index.
Args:
index: Item index
trigger_callback: Whether to trigger selection callback
close_popup: Whether to close popup after selection
"""
if 0 <= index < len(self.items):
self.current_index = index
self.selected_item = self.items[index]
# Update button text
display_text = self._get_display_text(self.selected_item)
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
self.dropdown_button.configure(text=truncated)
# Trigger callback
if trigger_callback and self.on_selection_changed:
self.on_selection_changed(self.selected_item)
# Close popup if requested
if close_popup:
self._close_dropdown()
def _highlight_item(self, index: int):
"""
Highlight an item in the popup.
Args:
index: Item index
"""
if not self.item_buttons:
return
for i, btn in enumerate(self.item_buttons):
if i == index:
btn.configure(
fg_color=("gray75", "gray25"),
border_color="blue"
)
else:
btn.configure(
fg_color="transparent",
border_color="gray50"
)
def _navigate_down(self, event=None):
"""Navigate to next item."""
if not self.items:
return
new_index = (self.current_index + 1) % len(self.items)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_up(self, event=None):
"""Navigate to previous item."""
if not self.items:
return
new_index = (self.current_index - 1) % len(self.items)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_page_down(self, event=None):
"""Jump down 5 items."""
if not self.items:
return
new_index = min(self.current_index + 5, len(self.items) - 1)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_page_up(self, event=None):
"""Jump up 5 items."""
if not self.items:
return
new_index = max(self.current_index - 5, 0)
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _navigate_home(self, event=None):
"""Jump to first item."""
if not self.items:
return
self.current_index = 0
self._highlight_item(0)
self._scroll_to_item(0)
def _navigate_end(self, event=None):
"""Jump to last item."""
if not self.items:
return
new_index = len(self.items) - 1
self.current_index = new_index
self._highlight_item(new_index)
self._scroll_to_item(new_index)
def _confirm_selection(self, event=None):
"""Confirm current selection and close popup."""
if self.current_index >= 0:
self._select_item(self.current_index, trigger_callback=True, close_popup=True)
def _scroll_to_item(self, index: int):
"""
Scroll popup to make item visible.
Args:
index: Item index
"""
if not self.popup_frame or not self.item_buttons:
return
# Get the button widget
if 0 <= index < len(self.item_buttons):
btn = self.item_buttons[index]
# Calculate scroll position
# Note: CTkScrollableFrame uses internal canvas
btn.update_idletasks()
def get_selected(self) -> Optional[Dict]:
"""
Get the currently selected item.
Returns:
Selected item dictionary or None
"""
return self.selected_item
def set_enabled(self, enabled: bool):
"""
Enable or disable the dropdown.
Args:
enabled: Whether to enable the dropdown
"""
if enabled and self.items:
self.dropdown_button.configure(state="normal")
else:
self.dropdown_button.configure(state="disabled")
def set_loading(self, loading: bool):
"""
Set loading state.
Args:
loading: Whether currently loading
"""
if loading:
self.dropdown_button.configure(state="disabled", text="Loading...")
if self.show_count:
self.count_label.configure(text="Loading...")
else:
if self.items:
self.dropdown_button.configure(state="normal")
if self.show_count:
count_text = f"{len(self.items)} item{'s' if len(self.items) != 1 else ''} found"
self.count_label.configure(text=count_text)
else:
self.dropdown_button.configure(state="disabled", text="No items found")
if self.show_count:
self.count_label.configure(text="0 items found")
def set_placeholder(self, text: str):
"""
Set placeholder text when no items.
Args:
text: Placeholder text
"""
if not self.items:
self.dropdown_button.configure(text=text)
+101
View File
@@ -0,0 +1,101 @@
"""
Login Frame
UI component for authentication.
"""
import customtkinter as ctk
from typing import Callable
class LoginFrame(ctk.CTkFrame):
"""Frame for authentication UI."""
def __init__(self, parent, on_connect: Callable = None):
"""
Initialize the login frame.
Args:
parent: Parent widget
on_connect: Callback function when connect button is clicked
"""
super().__init__(parent)
self.on_connect = on_connect
self.is_authenticated = False
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Title
self.title_label = ctk.CTkLabel(
self,
text="Authentication",
font=ctk.CTkFont(size=18, weight="bold")
)
self.title_label.grid(row=0, column=0, columnspan=3, padx=20, pady=(20, 10), sticky="w")
# Status indicator (colored circle)
self.status_indicator = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=20),
text_color="red"
)
self.status_indicator.grid(row=1, column=0, padx=(20, 5), pady=10)
# Status text
self.status_label = ctk.CTkLabel(
self,
text="Not Connected",
font=ctk.CTkFont(size=14)
)
self.status_label.grid(row=1, column=1, padx=5, pady=10, sticky="w")
# Connect button
self.connect_button = ctk.CTkButton(
self,
text="Connect to Azure",
command=self._on_connect_clicked,
width=200,
height=40,
font=ctk.CTkFont(size=14, weight="bold")
)
self.connect_button.grid(row=1, column=2, padx=20, pady=10, sticky="e")
# Configure grid
self.grid_columnconfigure(1, weight=1)
def _on_connect_clicked(self):
"""Handle connect button click."""
if self.on_connect:
self.on_connect()
def set_authenticated(self, authenticated: bool):
"""
Update authentication status.
Args:
authenticated: Whether authentication succeeded
"""
self.is_authenticated = authenticated
if authenticated:
self.status_indicator.configure(text_color="green")
self.status_label.configure(text="Connected")
self.connect_button.configure(state="disabled", text="Connected")
else:
self.status_indicator.configure(text_color="red")
self.status_label.configure(text="Not Connected")
self.connect_button.configure(state="normal", text="Connect to Azure")
def set_connecting(self):
"""Set UI to connecting state."""
self.status_indicator.configure(text_color="orange")
self.status_label.configure(text="Connecting...")
self.connect_button.configure(state="disabled", text="Connecting...")
def enable_button(self):
"""Enable the connect button (for retry after error)."""
if not self.is_authenticated:
self.connect_button.configure(state="normal", text="Connect to Azure")
+285
View File
@@ -0,0 +1,285 @@
"""
Main Window
Main GUI window that integrates all frames.
"""
import customtkinter as ctk
from ui.login_frame import LoginFrame
from ui.subscription_selection_frame import SubscriptionSelectionFrame
from ui.app_selection_frame import AppSelectionFrame
from ui.secret_generation_frame import SecretGenerationFrame
from ui.result_frame import ResultFrame
from typing import Callable, Dict, List
class MainWindow(ctk.CTk):
"""Main application window."""
def __init__(
self,
on_connect: Callable = None,
on_subscription_selected: Callable = None,
on_app_selected: Callable = None,
on_generate_secret: Callable = None,
on_generate_another: Callable = None
):
"""
Initialize the main window.
Args:
on_connect: Callback for authentication
on_subscription_selected: Callback when subscription is selected
on_app_selected: Callback when app is selected
on_generate_secret: Callback when generate secret is clicked
on_generate_another: Callback when generate another is clicked
"""
super().__init__()
# Configure window
self.title("Azure Key Vault Secret Manager")
self.geometry("950x850")
# Set application icon (if available)
self._set_icon()
# Configure grid
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# Title
self.title_label = ctk.CTkLabel(
self,
text="Azure Key Vault Secret Manager",
font=ctk.CTkFont(size=28, weight="bold")
)
self.title_label.grid(row=0, column=0, padx=20, pady=20, sticky="n")
# Scrollable frame for content with optimized scrolling
self.scroll_frame = ctk.CTkScrollableFrame(
self,
scrollbar_button_color=("gray75", "gray25"),
scrollbar_button_hover_color=("gray65", "gray35")
)
self.scroll_frame.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="nsew")
self.scroll_frame.grid_columnconfigure(0, weight=1)
# Optimize scroll step for smoother scrolling (40px = 2x faster than default 20px)
self.scroll_frame._parent_canvas.configure(yscrollincrement=40)
# Add smooth mouse wheel scrolling
def smooth_scroll(event):
"""Handle smooth mouse wheel scrolling."""
# Platform-specific delta handling
if event.delta > 0:
delta = -1 # Scroll up
else:
delta = 1 # Scroll down
self.scroll_frame._parent_canvas.yview_scroll(delta, "units")
return "break" # Prevent event propagation
# Bind mouse wheel event (Windows/Mac)
self.scroll_frame._parent_canvas.bind_all("<MouseWheel>", smooth_scroll, add="+")
# Login Frame
self.login_frame = LoginFrame(self.scroll_frame, on_connect=on_connect)
self.login_frame.grid(row=0, column=0, padx=0, pady=(0, 15), sticky="ew")
# Subscription Selection Frame
self.subscription_selection_frame = SubscriptionSelectionFrame(
self.scroll_frame,
on_subscription_selected=on_subscription_selected
)
self.subscription_selection_frame.grid(row=1, column=0, padx=0, pady=(0, 15), sticky="ew")
self.subscription_selection_frame.set_enabled(False)
# App Selection Frame
self.app_selection_frame = AppSelectionFrame(
self.scroll_frame,
on_app_selected=on_app_selected
)
self.app_selection_frame.grid(row=2, column=0, padx=0, pady=(0, 15), sticky="ew")
self.app_selection_frame.set_enabled(False)
# Secret Generation Frame
self.secret_generation_frame = SecretGenerationFrame(
self.scroll_frame,
on_generate=on_generate_secret
)
self.secret_generation_frame.grid(row=3, column=0, padx=0, pady=(0, 15), sticky="ew")
self.secret_generation_frame.set_enabled(False)
# Result Frame (initially hidden)
self.result_frame = ResultFrame(
self.scroll_frame,
on_generate_another=on_generate_another
)
self.result_frame.grid(row=4, column=0, padx=0, pady=(0, 15), sticky="ew")
self.result_frame.hide()
def set_authenticated(self, authenticated: bool):
"""
Update UI after authentication.
Args:
authenticated: Whether authentication succeeded
"""
self.login_frame.set_authenticated(authenticated)
if authenticated:
self.subscription_selection_frame.set_enabled(True)
def set_connecting(self):
"""Set UI to connecting state."""
self.login_frame.set_connecting()
def enable_connect_button(self):
"""Enable connect button for retry."""
self.login_frame.enable_button()
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
"""
Set the list of subscriptions.
Args:
subscriptions: List of subscription dictionaries
"""
self.subscription_selection_frame.set_subscriptions(subscriptions)
def set_subscription_selected(self):
"""Enable UI after subscription is selected."""
self.app_selection_frame.set_enabled(True)
self.secret_generation_frame.set_enabled(True)
def set_apps(self, apps: List[Dict[str, str]]):
"""
Set the list of applications.
Args:
apps: List of app dictionaries
"""
self.app_selection_frame.set_apps(apps)
def set_vaults(self, vaults: List[Dict[str, str]]):
"""
Set the list of Key Vaults.
Args:
vaults: List of vault dictionaries
"""
self.secret_generation_frame.set_vaults(vaults)
def set_loading_subscriptions(self, loading: bool):
"""
Set loading subscriptions state.
Args:
loading: Whether currently loading
"""
self.subscription_selection_frame.set_loading(loading)
def set_loading_apps(self, loading: bool):
"""
Set loading apps state.
Args:
loading: Whether currently loading
"""
self.app_selection_frame.set_loading(loading)
def set_loading_vaults(self, loading: bool):
"""
Set loading vaults state.
Args:
loading: Whether currently loading
"""
self.secret_generation_frame.set_loading_vaults(loading)
def set_generating(self, generating: bool):
"""
Set generating secret state.
Args:
generating: Whether currently generating
"""
self.secret_generation_frame.set_generating(generating)
def show_result(
self,
secret_name: str,
vault_name: str,
secret_value: str,
removed_count: int = 0
):
"""
Show secret generation result.
Args:
secret_name: The sanitized secret name
vault_name: The Key Vault name
secret_value: The secret value
removed_count: Number of old secrets removed
"""
self.result_frame.show_result(secret_name, vault_name, secret_value, removed_count)
def reset_form(self):
"""Reset the secret generation form."""
self.secret_generation_frame.reset()
self.result_frame.hide()
def get_selected_app(self) -> Dict[str, str]:
"""Get the currently selected app."""
return self.app_selection_frame.get_selected_app()
def get_description(self) -> str:
"""Get the secret description."""
return self.secret_generation_frame.get_description()
def get_selected_vault(self) -> Dict[str, str]:
"""Get the currently selected vault."""
return self.secret_generation_frame.get_selected_vault()
def get_remove_old_secrets(self) -> bool:
"""Get whether to remove old secrets."""
return self.secret_generation_frame.get_remove_old_secrets()
def _set_icon(self):
"""Set the application icon if available."""
import os
from pathlib import Path
# Get the directory where the script is located
script_dir = Path(__file__).parent.parent
# Try common icon file names and locations
icon_paths = [
script_dir / "icon.ico",
script_dir / "assets" / "icon.ico",
script_dir / "icon.png",
script_dir / "assets" / "icon.png",
]
for icon_path in icon_paths:
if icon_path.exists():
try:
if icon_path.suffix == '.ico':
# Use .ico file directly (Windows)
self.iconbitmap(str(icon_path))
print(f"Icon loaded: {icon_path}")
return
elif icon_path.suffix == '.png':
# Use .png file with iconphoto (cross-platform)
import tkinter as tk
from PIL import Image, ImageTk
img = Image.open(icon_path)
photo = ImageTk.PhotoImage(img)
self.iconphoto(True, photo)
print(f"Icon loaded: {icon_path}")
return
except Exception as e:
print(f"Failed to load icon from {icon_path}: {str(e)}")
# No icon found - use default
print("No custom icon found. Using default application icon.")
+197
View File
@@ -0,0 +1,197 @@
"""
Result Frame
UI component for displaying secret generation results.
"""
import customtkinter as ctk
from typing import Callable
class ResultFrame(ctk.CTkFrame):
"""Frame for displaying secret generation results."""
def __init__(self, parent, on_generate_another: Callable = None):
"""
Initialize the result frame.
Args:
parent: Parent widget
on_generate_another: Callback function when "Generate Another" is clicked
"""
super().__init__(parent)
self.on_generate_another = on_generate_another
# Configure frame with success colors
self.configure(
corner_radius=10,
border_width=3,
border_color="green",
fg_color=("#E8F5E9", "#1B5E20") # Light green / Dark green
)
# Success title
self.success_label = ctk.CTkLabel(
self,
text="✓ Secret Generated Successfully!",
font=ctk.CTkFont(size=20, weight="bold"),
text_color="green"
)
self.success_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(20, 15), sticky="w")
# Secret Name Label
self.name_title_label = ctk.CTkLabel(
self,
text="Secret Name:",
font=ctk.CTkFont(size=14, weight="bold")
)
self.name_title_label.grid(row=1, column=0, padx=20, pady=(0, 5), sticky="w")
self.name_value_label = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=14)
)
self.name_value_label.grid(row=1, column=1, padx=20, pady=(0, 5), sticky="w")
# Key Vault Label
self.vault_title_label = ctk.CTkLabel(
self,
text="Key Vault:",
font=ctk.CTkFont(size=14, weight="bold")
)
self.vault_title_label.grid(row=2, column=0, padx=20, pady=(0, 5), sticky="w")
self.vault_value_label = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=14)
)
self.vault_value_label.grid(row=2, column=1, padx=20, pady=(0, 5), sticky="w")
# Old Secrets Removed Label (initially hidden)
self.removed_title_label = ctk.CTkLabel(
self,
text="Old Secrets Removed:",
font=ctk.CTkFont(size=14, weight="bold")
)
self.removed_title_label.grid(row=3, column=0, padx=20, pady=(0, 15), sticky="w")
self.removed_title_label.grid_remove() # Hide initially
self.removed_value_label = ctk.CTkLabel(
self,
text="",
font=ctk.CTkFont(size=14)
)
self.removed_value_label.grid(row=3, column=1, padx=20, pady=(0, 15), sticky="w")
self.removed_value_label.grid_remove() # Hide initially
# Secret Value Label
self.secret_label = ctk.CTkLabel(
self,
text="Secret Value (copy this now):",
font=ctk.CTkFont(size=14, weight="bold")
)
self.secret_label.grid(row=4, column=0, columnspan=2, padx=20, pady=(10, 5), sticky="w")
# Secret Value Textbox (read-only)
self.secret_textbox = ctk.CTkTextbox(
self,
height=80,
font=ctk.CTkFont(family="Consolas", size=12),
wrap="word"
)
self.secret_textbox.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
# Buttons frame
self.buttons_frame = ctk.CTkFrame(self, fg_color="transparent")
self.buttons_frame.grid(row=6, column=0, columnspan=2, padx=20, pady=(0, 20))
# Copy button
self.copy_button = ctk.CTkButton(
self.buttons_frame,
text="Copy to Clipboard",
command=self._on_copy_clicked,
width=180,
height=40,
font=ctk.CTkFont(size=14, weight="bold")
)
self.copy_button.grid(row=0, column=0, padx=(0, 10))
# Generate Another button
self.another_button = ctk.CTkButton(
self.buttons_frame,
text="Generate Another Secret",
command=self._on_another_clicked,
width=200,
height=40,
font=ctk.CTkFont(size=14, weight="bold")
)
self.another_button.grid(row=0, column=1, padx=(10, 0))
# Configure grid
self.grid_columnconfigure(1, weight=1)
# Initially hide the frame
self.grid_remove()
def show_result(
self,
secret_name: str,
vault_name: str,
secret_value: str,
removed_count: int = 0
):
"""
Display secret generation result.
Args:
secret_name: The sanitized secret name
vault_name: The Key Vault name
secret_value: The secret value
removed_count: Number of old secrets removed (0 if not applicable)
"""
# Update labels
self.name_value_label.configure(text=secret_name)
self.vault_value_label.configure(text=vault_name)
# Show/hide removed count
if removed_count > 0:
self.removed_value_label.configure(text=str(removed_count))
self.removed_title_label.grid()
self.removed_value_label.grid()
else:
self.removed_title_label.grid_remove()
self.removed_value_label.grid_remove()
# Set secret value in textbox
self.secret_textbox.delete("1.0", "end")
self.secret_textbox.insert("1.0", secret_value)
self.secret_textbox.configure(state="disabled")
# Show the frame
self.grid()
def _on_copy_clicked(self):
"""Handle copy button click."""
# Get secret value from textbox
secret_value = self.secret_textbox.get("1.0", "end").strip()
# Copy to clipboard
self.clipboard_clear()
self.clipboard_append(secret_value)
# Show temporary confirmation
original_text = self.copy_button.cget("text")
self.copy_button.configure(text="✓ Copied!")
self.after(2000, lambda: self.copy_button.configure(text=original_text))
def _on_another_clicked(self):
"""Handle generate another button click."""
if self.on_generate_another:
self.on_generate_another()
def hide(self):
"""Hide the result frame."""
self.grid_remove()
+294
View File
@@ -0,0 +1,294 @@
"""
Secret Generation Frame
UI component for secret generation form.
"""
import customtkinter as ctk
from typing import List, Dict, Callable, Optional
class SecretGenerationFrame(ctk.CTkFrame):
"""Frame for secret generation form."""
def __init__(self, parent, on_generate: Callable = None):
"""
Initialize the secret generation frame.
Args:
parent: Parent widget
on_generate: Callback function when generate button is clicked
"""
super().__init__(parent)
self.on_generate = on_generate
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Title
self.title_label = ctk.CTkLabel(
self,
text="Secret Generation",
font=ctk.CTkFont(size=18, weight="bold")
)
self.title_label.grid(row=0, column=0, columnspan=2, padx=20, pady=(20, 15), sticky="w")
# Secret Description Label
self.description_label = ctk.CTkLabel(
self,
text="Secret Description:",
font=ctk.CTkFont(size=14)
)
self.description_label.grid(row=1, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w")
# Secret Description Entry
self.description_entry = ctk.CTkEntry(
self,
placeholder_text="e.g., Production API Key 2025",
height=40,
font=ctk.CTkFont(size=14)
)
self.description_entry.grid(row=2, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
# Key Vault Label
self.vault_label = ctk.CTkLabel(
self,
text="Select Key Vault:",
font=ctk.CTkFont(size=14)
)
self.vault_label.grid(row=3, column=0, columnspan=2, padx=20, pady=(0, 5), sticky="w")
# Key Vault Dropdown Button (simplified inline version)
self.vault_dropdown_button = ctk.CTkButton(
self,
text="Please select a subscription first",
command=self._open_vault_dropdown,
width=600,
height=40,
font=ctk.CTkFont(size=14),
anchor="w",
state="disabled"
)
self.vault_dropdown_button.grid(row=4, column=0, columnspan=2, padx=20, pady=(0, 15), sticky="ew")
# Store vaults and selection
self.vaults = []
self.selected_vault = None
self.vault_popup = None
# Remove old secrets checkbox
self.remove_old_checkbox = ctk.CTkCheckBox(
self,
text="Remove old secrets after creating new one",
font=ctk.CTkFont(size=14)
)
self.remove_old_checkbox.grid(row=5, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="w")
# Generate button
self.generate_button = ctk.CTkButton(
self,
text="Generate Secret",
command=self._on_generate_clicked,
height=50,
font=ctk.CTkFont(size=16, weight="bold"),
state="disabled"
)
self.generate_button.grid(row=6, column=0, columnspan=2, padx=20, pady=(0, 20), sticky="ew")
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_vaults(self, vaults: List[Dict[str, str]]):
"""
Set the list of Key Vaults.
Args:
vaults: List of vault dictionaries with 'name' and 'resource_group'
"""
self.vaults = vaults
if vaults:
self.vault_dropdown_button.configure(state="normal")
# Auto-select first vault
self._select_vault(vaults[0])
else:
self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found")
self.selected_vault = None
def _open_vault_dropdown(self):
"""Open the vault selection popup."""
if not self.vaults or self.vault_popup:
return
import tkinter as tk
# Create popup
self.vault_popup = tk.Toplevel(self)
self.vault_popup.wm_overrideredirect(True)
self.vault_popup.wm_attributes("-topmost", True)
# Calculate height
item_height = 44
calculated_height = len(self.vaults) * item_height + 10
popup_height = min(calculated_height, 300)
# Create scrollable frame
scroll_frame = ctk.CTkScrollableFrame(
self.vault_popup,
width=580,
height=popup_height
)
scroll_frame.pack(fill="both", expand=True)
# Configure scroll speed to match main window (40px per scroll unit = 2x faster)
scroll_frame._parent_canvas.configure(yscrollincrement=40)
# Create buttons
for idx, vault in enumerate(self.vaults):
display_text = f"{vault['name']} (RG: {vault['resource_group']})"
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
btn = ctk.CTkButton(
scroll_frame,
text=truncated,
command=lambda v=vault: self._select_vault_and_close(v),
font=ctk.CTkFont(size=14),
height=40,
fg_color="transparent",
border_width=1,
border_color="gray50",
hover_color=("gray70", "gray30"),
anchor="w"
)
btn.grid(row=idx, column=0, padx=5, pady=2, sticky="ew")
scroll_frame.grid_columnconfigure(0, weight=1)
# Bind mouse wheel
def popup_scroll(event):
if event.delta > 0:
scroll_frame._parent_canvas.yview_scroll(-1, "units")
else:
scroll_frame._parent_canvas.yview_scroll(1, "units")
return "break"
btn.bind("<MouseWheel>", popup_scroll, add="+")
# Position popup
self.vault_popup.update_idletasks()
x = self.vault_dropdown_button.winfo_rootx()
y = self.vault_dropdown_button.winfo_rooty() + self.vault_dropdown_button.winfo_height()
self.vault_popup.wm_geometry(f"+{x}+{y}")
# Bind events
self.vault_popup.bind("<Escape>", lambda e: self._close_vault_popup())
self.vault_popup.bind("<FocusOut>", lambda e: self._close_vault_popup())
self.vault_popup.focus_set()
def _select_vault(self, vault: Dict):
"""Select a vault and update button text."""
self.selected_vault = vault
display_text = f"{vault['name']} (RG: {vault['resource_group']})"
max_chars = 60
truncated = display_text if len(display_text) <= max_chars else display_text[:max_chars] + "..."
self.vault_dropdown_button.configure(text=truncated)
def _select_vault_and_close(self, vault: Dict):
"""Select vault and close popup."""
self._select_vault(vault)
self._close_vault_popup()
def _close_vault_popup(self):
"""Close the vault popup."""
if self.vault_popup:
self.vault_popup.destroy()
self.vault_popup = None
def _on_generate_clicked(self):
"""Handle generate button click."""
if self.on_generate:
description = self.description_entry.get().strip()
remove_old = self.remove_old_checkbox.get()
vault = self.get_selected_vault()
if description and vault:
self.on_generate(description, vault, remove_old)
def get_selected_vault(self) -> Optional[Dict[str, str]]:
"""
Get the currently selected Key Vault.
Returns:
Dict: Selected vault or None
"""
return self.selected_vault
def get_description(self) -> str:
"""
Get the secret description.
Returns:
str: Description text
"""
return self.description_entry.get().strip()
def get_remove_old_secrets(self) -> bool:
"""
Get whether to remove old secrets.
Returns:
bool: True if checkbox is checked
"""
return self.remove_old_checkbox.get()
def set_enabled(self, enabled: bool):
"""
Enable or disable the frame.
Args:
enabled: Whether to enable the frame
"""
if enabled:
self.description_entry.configure(state="normal")
if self.vaults:
self.vault_dropdown_button.configure(state="normal")
self.remove_old_checkbox.configure(state="normal")
self.generate_button.configure(state="normal")
else:
self.description_entry.configure(state="disabled")
self.vault_dropdown_button.configure(state="disabled")
self.remove_old_checkbox.configure(state="disabled")
self.generate_button.configure(state="disabled")
def set_generating(self, generating: bool):
"""
Set generating state.
Args:
generating: Whether currently generating
"""
if generating:
self.generate_button.configure(state="disabled", text="Generating...")
else:
self.generate_button.configure(state="normal", text="Generate Secret")
def set_loading_vaults(self, loading: bool):
"""
Set loading vaults state.
Args:
loading: Whether currently loading
"""
if loading:
self.vault_dropdown_button.configure(state="disabled", text="Loading Key Vaults...")
else:
if self.vaults:
self.vault_dropdown_button.configure(state="normal")
else:
self.vault_dropdown_button.configure(state="disabled", text="No Key Vaults found")
def reset(self):
"""Reset the form to initial state."""
self.description_entry.delete(0, 'end')
self.remove_old_checkbox.deselect()
+89
View File
@@ -0,0 +1,89 @@
"""
Subscription Selection Frame
UI component for selecting an Azure subscription.
"""
import customtkinter as ctk
from typing import List, Dict, Callable, Optional
from ui.components import UnifiedDropdown
class SubscriptionSelectionFrame(ctk.CTkFrame):
"""Frame for subscription selection."""
def __init__(self, parent, on_subscription_selected: Callable = None):
"""
Initialize the subscription selection frame.
Args:
parent: Parent widget
on_subscription_selected: Callback function when a subscription is selected
"""
super().__init__(parent)
self.on_subscription_selected = on_subscription_selected
# Configure frame
self.configure(corner_radius=10, border_width=2)
# Create unified dropdown
self.dropdown = UnifiedDropdown(
self,
title="Subscription Selection",
on_selection_changed=self._on_subscription_selected,
show_count=True,
display_key='name',
max_dropdown_height=300
)
self.dropdown.grid(row=0, column=0, sticky="ew")
self.dropdown.set_placeholder("Please connect to Azure first")
# Configure grid
self.grid_columnconfigure(0, weight=1)
def set_subscriptions(self, subscriptions: List[Dict[str, str]]):
"""
Set the list of subscriptions.
Args:
subscriptions: List of subscription dictionaries with 'id' and 'name'
"""
self.dropdown.set_items(subscriptions)
def _on_subscription_selected(self, subscription: Dict):
"""
Handle subscription selection.
Args:
subscription: The selected subscription dictionary
"""
if self.on_subscription_selected:
self.on_subscription_selected(subscription)
def get_selected_subscription(self) -> Optional[Dict[str, str]]:
"""
Get the currently selected subscription.
Returns:
Dict: Selected subscription or None
"""
return self.dropdown.get_selected()
def set_enabled(self, enabled: bool):
"""
Enable or disable the frame.
Args:
enabled: Whether to enable the frame
"""
self.dropdown.set_enabled(enabled)
def set_loading(self, loading: bool):
"""
Set loading state.
Args:
loading: Whether currently loading
"""
self.dropdown.set_loading(loading)
View File
+108
View File
@@ -0,0 +1,108 @@
"""
Async Worker Module
Provides a persistent async worker thread for executing coroutines.
Solves "Event loop is closed" errors by maintaining a single event loop.
"""
import asyncio
import threading
from typing import Any, Optional
from concurrent.futures import Future
import logging
class AsyncWorker:
"""
Persistent async worker thread for executing coroutines.
This worker maintains a single event loop for the application's lifetime,
solving the "Event loop is closed" error by ensuring all async operations
use the same loop and credentials remain valid.
"""
def __init__(self):
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.thread: Optional[threading.Thread] = None
self.running = False
self.logger = logging.getLogger(__name__)
def start(self):
"""Start the async worker thread."""
if self.running:
return
self.running = True
self.thread = threading.Thread(
target=self._run_loop,
daemon=True,
name="AsyncWorker"
)
self.thread.start()
# Wait for loop to be ready
import time
while self.loop is None:
time.sleep(0.01)
def _run_loop(self):
"""Run the event loop in the worker thread."""
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.logger.info("AsyncWorker event loop started")
try:
self.loop.run_forever()
finally:
self.loop.close()
self.logger.info("AsyncWorker event loop closed")
def submit(self, coro) -> Future:
"""
Submit a coroutine to be executed in the worker loop.
Args:
coro: Coroutine to execute
Returns:
Future that will contain the result
"""
if not self.running:
raise RuntimeError("AsyncWorker not started")
result_future = Future()
def callback():
"""Execute coroutine and set result in future."""
try:
task = asyncio.ensure_future(coro, loop=self.loop)
def done_callback(task_future):
try:
result = task_future.result()
result_future.set_result(result)
except Exception as e:
result_future.set_exception(e)
task.add_done_callback(done_callback)
except Exception as e:
result_future.set_exception(e)
self.loop.call_soon_threadsafe(callback)
return result_future
def stop(self):
"""Stop the async worker thread."""
if not self.running:
return
self.running = False
if self.loop:
self.loop.call_soon_threadsafe(self.loop.stop)
if self.thread:
self.thread.join(timeout=5.0)
self.logger.info("AsyncWorker stopped")
+69
View File
@@ -0,0 +1,69 @@
"""
Logging Utility
Configures logging for the application.
"""
import logging
import os
from datetime import datetime
def setup_logger(name: str = 'AzureKeyVaultManager', log_file: str = None, level=logging.INFO):
"""
Set up and configure a logger.
Args:
name: Logger name
log_file: Optional log file path (defaults to logs/app_{date}.log)
level: Logging level (default: INFO)
Returns:
logging.Logger: Configured logger instance
"""
logger = logging.getLogger(name)
logger.setLevel(level)
# Remove existing handlers
logger.handlers = []
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(level)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# File handler (optional)
if log_file:
# Create logs directory if it doesn't exist
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
else:
# Default log file in logs directory
log_dir = 'logs'
if not os.path.exists(log_dir):
os.makedirs(log_dir)
log_file = os.path.join(log_dir, f'app_{datetime.now().strftime("%Y%m%d")}.log')
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
# Create default logger instance
logger = setup_logger()
+37
View File
@@ -0,0 +1,37 @@
"""
Name Sanitization Utility
Sanitizes names for use in Azure Key Vault.
"""
import re
def sanitize_name(name: str) -> str:
"""
Sanitize a name for use in Azure Key Vault.
Key Vault secret names can only contain alphanumeric characters and hyphens.
Args:
name: The name to sanitize
Returns:
str: Sanitized name
Example:
>>> sanitize_name("My App (Production) #2024")
'My-App-Production-2024'
"""
if not name:
return name
# Replace any non-alphanumeric character (except hyphens) with hyphen
sanitized = re.sub(r'[^0-9a-zA-Z-]', '-', name)
# Remove consecutive hyphens
sanitized = re.sub(r'-+', '-', sanitized)
# Remove leading/trailing hyphens
sanitized = sanitized.strip('-')
return sanitized