diff --git a/.github/instructions/python-lang.instructions.md b/.github/instructions/python-lang.instructions.md index 0489d3d8c..9f8c6a371 100644 --- a/.github/instructions/python-lang.instructions.md +++ b/.github/instructions/python-lang.instructions.md @@ -44,3 +44,5 @@ applyTo: '**/*.py' - Always use f-strings for string interpolation. Ex: `f"User ID: {user_id}"` instead of `"User ID: {}".format(user_id)"` - Never use `except:` without specifying the exception type. Always catch specific exceptions or use `except Exception as ex:` to capture the exception details. This also avoids accidentally catching system-exiting exceptions like `KeyboardInterrupt` or `SystemExit`. + +- Never return raw exception text to browser or API clients. Do not use `str(e)`, `str(exc)`, `repr(e)`, traceback text, SDK exception messages, connection strings, or provider errors in `jsonify()`, template rendering, streaming responses, or other client-visible payloads. Log exception type and safe contextual metadata server-side with `log_event` or `debug_print`, then return a stable user-safe message such as `"Invalid request."`, `"Unable to save action."`, or `"Unable to store secrets in Key Vault."` diff --git a/application/single_app/background_tasks.py b/application/single_app/background_tasks.py index e3771ba15..2712fac9a 100644 --- a/application/single_app/background_tasks.py +++ b/application/single_app/background_tasks.py @@ -35,6 +35,10 @@ from functions_debug import debug_print from functions_data_management import check_due_data_management_jobs_once from functions_file_sync import check_due_file_sync_sources_once +from functions_keyvault_reminders import ( + KEY_VAULT_SECRET_REMINDER_LOCK_NAME, + check_due_key_vault_secret_reminders_once, +) from functions_tabular_generated_exports import check_due_tabular_generated_output_runs_once from functions_personal_workflows import ( compute_next_run_at, @@ -782,6 +786,35 @@ def run_app_maintenance_loop(): time.sleep(max(int(sleep_seconds or 3600), 15)) +def run_key_vault_secret_reminder_loop(): + """Run due Key Vault secret expiration reminder checks under a distributed lock.""" + while True: + lock_document = None + sleep_seconds = 21600 + try: + settings = get_settings() + sleep_seconds = int(settings.get('key_vault_secret_expiration_scan_interval_seconds') or 21600) + if settings.get('enable_key_vault_secret_expiration_reminders'): + lock_document = acquire_distributed_task_lock( + KEY_VAULT_SECRET_REMINDER_LOCK_NAME, + lease_seconds=600, + ) + if lock_document: + check_due_key_vault_secret_reminders_once(settings=settings) + except Exception as exc: + log_event( + '[KeyVaultReminders] Error in reminder scheduler loop.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + finally: + if lock_document: + release_distributed_task_lock(lock_document) + + time.sleep(max(int(sleep_seconds or 21600), 900)) + + def start_background_task_threads(app=None): """Start all background task loops for the current process.""" task_specs = [ @@ -795,6 +828,7 @@ def start_background_task_threads(app=None): ('Tabular generated-output scheduler background task started.', run_tabular_generated_output_scheduler_loop), ('Data Management scheduler background task started.', lambda: run_data_management_scheduler_loop(app=app)), ('App maintenance background task started.', run_app_maintenance_loop), + ('Key Vault secret reminder background task started.', run_key_vault_secret_reminder_loop), ] started_threads = [] diff --git a/application/single_app/config.py b/application/single_app/config.py index f8a3d19ff..e2ce34f7e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -95,7 +95,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.120" +VERSION = "0.250.124" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') @@ -650,6 +650,12 @@ def _create_container_if_not_exists_with_conflict_recovery(*args, **kwargs): partition_key=PartitionKey(path="/scope_key") ) +cosmos_key_vault_secret_reminders_container_name = "key_vault_secret_reminders" +cosmos_key_vault_secret_reminders_container = cosmos_database.create_container_if_not_exists( + id=cosmos_key_vault_secret_reminders_container_name, + partition_key=PartitionKey(path="/scope_key") +) + cosmos_personal_file_sync_sources_container_name = "personal_file_sync_sources" cosmos_personal_file_sync_sources_container = cosmos_database.create_container_if_not_exists( id=cosmos_personal_file_sync_sources_container_name, diff --git a/application/single_app/functions_appinsights.py b/application/single_app/functions_appinsights.py index 50b508f41..f668a5a9d 100644 --- a/application/single_app/functions_appinsights.py +++ b/application/single_app/functions_appinsights.py @@ -4,6 +4,7 @@ import os import re import threading +from datetime import date, datetime from typing import Any, Dict, Optional, Tuple from azure.monitor.opentelemetry import configure_azure_monitor @@ -32,6 +33,14 @@ "subscriptionkey", "token", ) +EXTERNAL_EVENT_SENSITIVE_KEY_FRAGMENTS = ( + "email", + "userid", + "groupid", + "publicworkspaceid", + "scopevalue", + "sourceid", +) SECRET_ASSIGNMENT_RE = re.compile( r"(?i)\b(api[-_]?key|access[-_]?token|client[-_]?secret|connection[-_]?string|password|secret|subscription[-_]?key|token|sig|signature)=([^&\s,;]+)" ) @@ -64,6 +73,8 @@ LOGGER_EVENT_MESSAGE = "[SimpleChatLogEvent]" LOGGER_DEBUG_MESSAGE = "[SimpleChatDebugTrace]" LOGGER_FALLBACK_MESSAGE = "[SimpleChatLogFallback]" +LOGGER_EXTERNAL_EVENT_MESSAGE = "[SimpleChatExternalEvent]" +MAX_EXTERNAL_EVENT_STRING_LENGTH = 256 def _format_message(message: Any, message_args: Optional[Tuple[Any, ...]] = None) -> str: @@ -90,6 +101,18 @@ def _is_sensitive_log_key(key: Any) -> bool: return any(fragment in normalized_key for fragment in SENSITIVE_LOG_KEY_FRAGMENTS) +def _is_sensitive_external_event_key(key: Any) -> bool: + normalized_key = _normalize_log_key(key) + if not normalized_key: + return False + if normalized_key.endswith("hash"): + return False + return ( + _is_sensitive_log_key(key) + or any(fragment in normalized_key for fragment in EXTERNAL_EVENT_SENSITIVE_KEY_FRAGMENTS) + ) + + def sanitize_log_message(message: Any) -> str: """Redact secret-like values from log messages while preserving diagnostic text.""" message_text = str(message) @@ -176,6 +199,61 @@ def _build_logger_extra( return logger_extra +def _normalize_event_name(event_name: Any) -> str: + normalized_name = re.sub(r"[^A-Za-z0-9_.-]", "_", str(event_name or "").strip())[:100] + return normalized_name.strip("._-") or "simplechat_event" + + +def _normalize_external_event_key(key: Any) -> str: + normalized_key = re.sub(r"[^A-Za-z0-9_]", "_", str(key or "").strip())[:80] + normalized_key = normalized_key.strip("_") or "value" + return f"sc_event_{normalized_key}" + + +def _normalize_external_event_value(value: Any) -> Any: + if value is None: + return "" + if isinstance(value, bool) or isinstance(value, (int, float)): + return value + if isinstance(value, (date, datetime)): + return value.isoformat() + if isinstance(value, str): + return sanitize_log_message(value)[:MAX_EXTERNAL_EVENT_STRING_LENGTH] + if isinstance(value, dict): + return len(value) + if isinstance(value, (list, tuple, set)): + return len(value) + return type(value).__name__ + + +def _build_external_event_extra( + event_name: str, + extra: Optional[Dict[str, Any]] = None, + allowed_sensitive_dimensions: Optional[Tuple[str, ...]] = None, +) -> Dict[str, Any]: + event_extra: Dict[str, Any] = { + "sc_event_name": event_name, + } + allowed_sensitive_keys = { + _normalize_log_key(key) + for key in (allowed_sensitive_dimensions or ()) + if _normalize_log_key(key) + } + + if isinstance(extra, dict): + for key, value in extra.items(): + normalized_key = _normalize_external_event_key(key) + if ( + _is_sensitive_external_event_key(key) + and _normalize_log_key(key) not in allowed_sensitive_keys + ): + event_extra[f"{normalized_key}_present"] = value is not None + continue + event_extra[normalized_key] = _normalize_external_event_value(value) + + return event_extra + + def _load_logging_settings() -> Dict[str, Any]: """Read cached settings first and fall back to live settings when needed.""" if getattr(_logging_settings_load_state, 'active', False): @@ -398,6 +476,51 @@ def log_event( if safe_extra: print("[LOG] Extra dimensions were redacted for logging safety.") + +def log_external_event( + event_name: str, + extra: Optional[Dict[str, Any]] = None, + level: int = logging.INFO, + allowed_sensitive_dimensions: Optional[Tuple[str, ...]] = None, +) -> None: + """ + Emit a privacy-safe, queryable telemetry event for Azure Monitor automation. + + Unlike general-purpose log_event records, this helper preserves explicitly + safe string dimensions so scheduled query alerts and downstream automation + can filter by event name and categorical fields. + """ + normalized_event_name = _normalize_event_name(event_name) + event_extra = _build_external_event_extra( + normalized_event_name, + extra, + allowed_sensitive_dimensions=allowed_sensitive_dimensions, + ) + + try: + logger = get_appinsights_logger() + if not logger: + logger = logging.getLogger('standard') + if not logger.handlers: + logger.addHandler(logging.StreamHandler()) + logger.setLevel(logging.INFO) + + logger.log( + level, + LOGGER_EXTERNAL_EVENT_MESSAGE, + extra=event_extra, + stacklevel=2, + ) + except Exception as exc: + log_event( + "[AppInsights] Failed to emit external telemetry event.", + extra={ + "event_name": normalized_event_name, + "error_type": type(exc).__name__, + }, + level=logging.ERROR, + ) + # --- Modern Azure Monitor Application Insights setup --- def setup_appinsights_logging(settings): """ diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 5203927de..94a676ae5 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -162,7 +162,7 @@ def save_global_action(action_data, user_id=None): except Exception as e: print(f"❌ Error saving global action: {str(e)}") traceback.print_exc() - return None + raise def delete_global_action(action_id): @@ -229,4 +229,3 @@ def update_global_action_enabled(action_id, is_enabled, user_id=None): print(f"❌ Error updating enabled state for global action {action_id}: {str(e)}") traceback.print_exc() return None - diff --git a/application/single_app/functions_keyvault.py b/application/single_app/functions_keyvault.py index 899e11e32..ea2ad1fba 100644 --- a/application/single_app/functions_keyvault.py +++ b/application/single_app/functions_keyvault.py @@ -2,6 +2,7 @@ import re import logging +from datetime import datetime, timezone from urllib.parse import urlparse from functions_appinsights import log_event from config import * @@ -72,6 +73,9 @@ }, ] REDACTED_SECRET_VALUE = "***REDACTED***" +KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD = "key_vault_secret_reminders" +KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS = "sync_failed" +KEY_VAULT_SECRET_REMINDER_SYNCED_STATUS = "synced" class SecretReturnType(Enum): VALUE = "value" @@ -79,6 +83,168 @@ class SecretReturnType(Enum): NAME = "name" +def _format_key_vault_secret_field_label(path): + """Return a readable label for a secret field path.""" + return ".".join(path).replace("additionalFields.", "").replace("_", " ") + + +def _get_key_vault_secret_expiration_datetime(expires_on): + """Return a UTC datetime suitable for Azure Key Vault secret properties.""" + if isinstance(expires_on, datetime): + parsed_datetime = expires_on + else: + raw_value = str(expires_on or "").strip() + if not raw_value: + raise ValueError("expires_on is required.") + if raw_value.endswith("Z"): + raw_value = f"{raw_value[:-1]}+00:00" + if len(raw_value) == 10: + raw_value = f"{raw_value}T00:00:00+00:00" + parsed_datetime = datetime.fromisoformat(raw_value) + + if parsed_datetime.tzinfo is None: + return parsed_datetime.replace(tzinfo=timezone.utc) + return parsed_datetime.astimezone(timezone.utc) + + +def update_key_vault_secret_expiration(secret_name, expires_on): + """Update the expiration metadata on the latest version of a Key Vault secret.""" + settings = app_settings_cache.get_settings_cache() + if not settings.get("enable_key_vault_secret_storage", False): + raise ValueError("Key Vault secret storage is not enabled in settings.") + + key_vault_name = settings.get("key_vault_name", None) + if not key_vault_name: + raise ValueError("Key Vault name is not configured.") + if not validate_secret_name_dynamic(secret_name): + raise ValueError("Secret name is not a SimpleChat Key Vault reference.") + + key_vault_url = f"https://{key_vault_name}{KEY_VAULT_DOMAIN}" + secret_client = SecretClient(vault_url=key_vault_url, credential=get_keyvault_credential()) + secret_client.update_secret_properties( + name=secret_name, + expires_on=_get_key_vault_secret_expiration_datetime(expires_on), + ) + log_event( + f"Secret '{secret_name}' expiration updated successfully in Key Vault.", + level=logging.INFO, + ) + return True + + +def _record_plugin_reminder_sync_status(updated_plugin, path, status, error=""): + """Persist reminder sync status in action metadata for save responses and diagnostics.""" + metadata = updated_plugin.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + updated_plugin["metadata"] = metadata + sync_status = metadata.get("key_vault_secret_reminder_sync") + if not isinstance(sync_status, dict): + sync_status = {} + metadata["key_vault_secret_reminder_sync"] = sync_status + + sync_status[".".join(path)] = { + "status": status, + "error": str(error or "")[:1000], + "updated_at": datetime.now(timezone.utc).isoformat(), + } + + +def _build_plugin_reminder_context(updated_plugin, path, secret_reference, scope_value, source, scope): + """Build the admin inventory context for a plugin secret reminder.""" + settings = app_settings_cache.get_settings_cache() + source_id = updated_plugin.get("id") or scope_value + configured_by = ( + updated_plugin.get("modified_by") + or updated_plugin.get("created_by") + or updated_plugin.get("user_id") + or "" + ) + try: + configured_by = configured_by or str(get_current_user_id() or "") + except Exception: + configured_by = configured_by or "" + + context = { + "secret_name": secret_reference, + "key_vault_name": settings.get("key_vault_name", ""), + "scope": scope, + "scope_value": scope_value, + "source": source, + "source_type": "action", + "source_id": source_id, + "source_name": updated_plugin.get("name", ""), + "source_display_name": updated_plugin.get("displayName") or updated_plugin.get("name", ""), + "field_path": ".".join(path), + "field_label": _format_key_vault_secret_field_label(path), + "configured_by": configured_by, + "remediation_url": "/admin/settings?tab=security#keyvault-section", + } + if scope == "user": + context["owner_user_id"] = scope_value + elif scope == "group": + context["group_id"] = scope_value + elif scope == "public": + context["public_workspace_id"] = scope_value + return context + + +def _sync_plugin_secret_reminder(updated_plugin, path, secret_reference, scope_value, source, scope): + """Sync optional expiration reminder metadata for a plugin Key Vault secret reference.""" + if not validate_secret_name_dynamic(secret_reference): + return + + metadata = updated_plugin.get("metadata") + if not isinstance(metadata, dict) or not isinstance(metadata.get(KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD), dict): + return + + from functions_keyvault_reminders import ( + mark_key_vault_secret_reminder_disabled, + resolve_key_vault_secret_reminder_config, + upsert_key_vault_secret_reminder, + ) + + reminder_config = resolve_key_vault_secret_reminder_config(updated_plugin, path) + if reminder_config is None: + return + + context = _build_plugin_reminder_context( + updated_plugin, + path, + secret_reference, + scope_value, + source, + scope, + ) + + if not reminder_config.get("enabled"): + mark_key_vault_secret_reminder_disabled(secret_reference, context=context) + _record_plugin_reminder_sync_status(updated_plugin, path, "disabled") + return + + sync_status = KEY_VAULT_SECRET_REMINDER_SYNCED_STATUS + sync_error = "" + try: + update_key_vault_secret_expiration(secret_reference, reminder_config["expires_on"]) + except Exception as exc: + sync_status = KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS + sync_error = str(exc) + log_event( + f"Failed to sync Key Vault secret expiration for '{secret_reference}': {exc}", + level=logging.ERROR, + exceptionTraceback=True, + ) + + upsert_key_vault_secret_reminder( + secret_reference, + reminder_config, + context, + key_vault_sync_status=sync_status, + key_vault_sync_error=sync_error, + ) + _record_plugin_reminder_sync_status(updated_plugin, path, sync_status, sync_error) + + def _normalize_allowed_sources(allowed_sources): """Normalize one or many allowed sources into a comparable set.""" if allowed_sources is None: @@ -284,13 +450,18 @@ def _store_plugin_secret_reference(updated_plugin, existing_plugin, path, secret f"Stored Key Vault reference for '{path_label}' no longer matches the expected scope. Re-enter the secret value." ) _set_nested_dict_value(updated_plugin, path, existing_reference) + _sync_plugin_secret_reminder( + updated_plugin, + path, + existing_reference, + scope_value, + source, + scope, + ) return - _set_nested_dict_value( - updated_plugin, - path, - build_full_secret_name(secret_name, scope_value, source, scope), + raise ValueError( + f"Stored Key Vault placeholder for '{path_label}' has no existing secret reference. Re-enter the secret value." ) - return if validate_secret_name_dynamic(value): if not secret_reference_matches_context( @@ -310,6 +481,14 @@ def _store_plugin_secret_reference(updated_plugin, existing_plugin, path, secret f"Stored Key Vault reference for '{path_label}' does not match the expected scope." ) _set_nested_dict_value(updated_plugin, path, value) + _sync_plugin_secret_reminder( + updated_plugin, + path, + value, + scope_value, + source, + scope, + ) return full_secret_name = store_secret_in_key_vault( @@ -320,6 +499,14 @@ def _store_plugin_secret_reference(updated_plugin, existing_plugin, path, secret scope=scope, ) _set_nested_dict_value(updated_plugin, path, full_secret_name) + _sync_plugin_secret_reminder( + updated_plugin, + path, + full_secret_name, + scope_value, + source, + scope, + ) def _store_mcp_custom_header_references(updated_plugin, existing_plugin, plugin_name, scope_value, scope): @@ -388,12 +575,9 @@ def _store_secret_reference(updated, existing, path, secret_name, scope_value, s ) _set_nested_dict_value(updated, path, existing_reference) return - _set_nested_dict_value( - updated, - path, - build_full_secret_name(secret_name, scope_value, source, scope), + raise ValueError( + f"Stored Key Vault placeholder for '{path_label}' has no existing secret reference. Re-enter the secret value." ) - return if validate_secret_name_dynamic(value): if not secret_reference_matches_context( @@ -672,8 +856,8 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl key_vault_name = settings.get("key_vault_name", None) if not key_vault_name: - log_event("Key Vault name is not configured.", level=logging.WARNING) - return secret_value + log_event("Key Vault name is not configured.", level=logging.ERROR) + raise ValueError("Key Vault name is not configured.") if source not in supported_sources: log_event(f"Source '{source}' is not supported. Supported sources: {supported_sources}", level=logging.ERROR) @@ -692,7 +876,7 @@ def store_secret_in_key_vault(secret_name, secret_value, scope_value, source="gl return full_secret_name except Exception as e: log_event(f"Failed to store secret '{full_secret_name}' in Key Vault: {str(e)}", level=logging.ERROR, exceptionTraceback=True) - return secret_value + raise RuntimeError(f"Failed to store secret '{full_secret_name}' in Key Vault.") from e def build_full_secret_name(secret_name, scope_value, source, scope): """ @@ -773,13 +957,15 @@ def keyvault_agent_save_helper(agent_dict, scope_value, scope="global", existing source, scope, ) + except ValueError: + raise except Exception as e: log_event( f"Failed to store agent key '{'.'.join(path)}' in Key Vault: {e}", level=logging.ERROR, exceptionTraceback=True, ) - raise Exception(f"Failed to store agent key '{'.'.join(path)}' in Key Vault: {e}") + raise RuntimeError(f"Failed to store agent key '{'.'.join(path)}' in Key Vault: {e}") from e return updated def keyvault_agent_get_helper(agent_dict, scope_value, scope="global", return_type=SecretReturnType.TRIGGER): @@ -879,9 +1065,11 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global", existi source, scope, ) + except ValueError: + raise except Exception as e: log_event(f"Failed to store plugin key in Key Vault: {e}", level=logging.ERROR, exceptionTraceback=True) - raise Exception(f"Failed to store plugin key in Key Vault: {e}") + raise RuntimeError(f"Failed to store plugin key in Key Vault: {e}") from e else: log_event(f"Auth type '{auth_type}' does not require Key Vault storage for plugin '{plugin_name}'.", level=logging.INFO) @@ -897,13 +1085,15 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global", existi source, scope, ) + except ValueError: + raise except Exception as e: log_event( f"Failed to store plugin auth secret '{auth_field}' in Key Vault: {e}", level=logging.ERROR, exceptionTraceback=True, ) - raise Exception(f"Failed to store plugin auth secret '{auth_field}' in Key Vault: {e}") + raise RuntimeError(f"Failed to store plugin auth secret '{auth_field}' in Key Vault: {e}") from e # Handle additionalFields dynamic secrets additional_fields = updated.get('additionalFields', {}) @@ -919,13 +1109,15 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global", existi scope_value, scope, ) + except ValueError: + raise except Exception as e: log_event( f"Failed to store MCP custom header secrets for action '{plugin_name}': {e}", level=logging.ERROR, exceptionTraceback=True, ) - raise Exception(f"Failed to store MCP custom header secrets for action '{plugin_name}': {e}") + raise RuntimeError(f"Failed to store MCP custom header secrets for action '{plugin_name}': {e}") from e for k, v in additional_fields.items(): if not v: @@ -944,9 +1136,11 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global", existi addset_source, scope, ) + except ValueError: + raise except Exception as e: log_event(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}", level=logging.ERROR, exceptionTraceback=True) - raise Exception(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") + raise RuntimeError(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") from e elif _is_sensitive_plugin_additional_field(updated, k): addset_source = 'action-addset' akv_key = _build_plugin_additional_field_secret_name(plugin_name, k) @@ -960,13 +1154,15 @@ def keyvault_plugin_save_helper(plugin_dict, scope_value, scope="global", existi addset_source, scope, ) + except ValueError: + raise except Exception as e: log_event( f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}", level=logging.ERROR, exceptionTraceback=True, ) - raise Exception(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") + raise RuntimeError(f"Failed to store plugin additionalField secret '{k}' in Key Vault: {e}") from e return updated # Helper to retrieve plugin secrets from Key Vault def keyvault_plugin_get_helper(plugin_dict, scope_value, scope="global", return_type=SecretReturnType.TRIGGER): @@ -1124,10 +1320,28 @@ def keyvault_model_endpoint_save_helper(endpoint_dict, scope_value, scope="globa if existing_reference: updated_auth[auth_field] = existing_reference else: - updated_auth.pop(auth_field, None) + raise ValueError( + f"Stored Key Vault placeholder for model endpoint '{auth_field}' has no existing secret reference. Re-enter the secret value." + ) continue if validate_secret_name_dynamic(value): + if not secret_reference_matches_context( + value, + scope_value=scope_value, + scope=scope, + allowed_sources={source}, + ): + _log_secret_reference_context_mismatch( + value, + f"model endpoint auth field '{auth_field}'", + scope_value=scope_value, + scope=scope, + allowed_sources={source}, + ) + raise ValueError( + f"Stored Key Vault reference for model endpoint '{auth_field}' does not match the expected scope." + ) updated_auth[auth_field] = value continue diff --git a/application/single_app/functions_keyvault_reminders.py b/application/single_app/functions_keyvault_reminders.py new file mode 100644 index 000000000..4313b0232 --- /dev/null +++ b/application/single_app/functions_keyvault_reminders.py @@ -0,0 +1,596 @@ +# functions_keyvault_reminders.py + +"""Key Vault secret expiration reminder inventory and notification helpers.""" + +import hashlib +import logging +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +from config import cosmos_key_vault_secret_reminders_container +from functions_appinsights import log_event, log_external_event +from functions_notifications import ( + create_group_notification, + create_notification, + create_public_workspace_notification, +) +from functions_settings import get_settings + + +KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD = "key_vault_secret_reminders" +KEY_VAULT_SECRET_REMINDER_ALL_FIELDS = "__all__" +KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE = "key_vault_secret_expiring" +KEY_VAULT_SECRET_REMINDER_ACTIVE_STATUS = "active" +KEY_VAULT_SECRET_REMINDER_DISABLED_STATUS = "disabled" +KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS = "sync_failed" +KEY_VAULT_SECRET_REMINDER_SYNCED_STATUS = "synced" +KEY_VAULT_SECRET_REMINDER_DEFAULT_LEAD_DAYS = 30 +KEY_VAULT_SECRET_REMINDER_DEFAULT_SCAN_INTERVAL_SECONDS = 21600 +KEY_VAULT_SECRET_REMINDER_LOCK_NAME = "key_vault_secret_expiration_reminders" +KEY_VAULT_REMINDER_EXTERNAL_EVENT_NAME = "key_vault_expiration_reminder_triggered" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _safe_int(value: Any, default_value: int, minimum: int, maximum: int) -> int: + try: + parsed_value = int(value) + except (TypeError, ValueError): + parsed_value = default_value + return min(max(parsed_value, minimum), maximum) + + +def _normalize_text(value: Any, max_length: int) -> str: + return str(value or "").strip()[:max_length] + + +def _hash_external_telemetry_value(value: Any) -> str: + normalized_value = str(value or "").strip() + if not normalized_value: + return "" + return hashlib.sha256(normalized_value.encode("utf-8")).hexdigest()[:16] + + +def _parse_expiration_date(value: Any) -> Optional[date]: + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + + raw_value = str(value).strip() + if not raw_value: + return None + if raw_value.endswith("Z"): + raw_value = f"{raw_value[:-1]}+00:00" + + try: + return datetime.fromisoformat(raw_value).date() + except ValueError: + return date.fromisoformat(raw_value[:10]) + + +def _format_scope_key(scope: str, scope_value: str) -> str: + return f"{scope}:{scope_value}" + + +def build_key_vault_secret_reminder_id(secret_name: str) -> str: + """Return a stable inventory id for a Key Vault secret name.""" + digest = hashlib.sha256(str(secret_name or "").encode("utf-8")).hexdigest()[:32] + return f"key-vault-secret-reminder-{digest}" + + +def normalize_key_vault_reminder_settings(settings: Dict[str, Any]) -> Dict[str, Any]: + """Normalize reminder-related app settings in-place and return the same dict.""" + settings["enable_key_vault_secret_expiration_reminders"] = _as_bool( + settings.get("enable_key_vault_secret_expiration_reminders", False) + ) + settings["key_vault_secret_expiration_default_lead_days"] = _safe_int( + settings.get("key_vault_secret_expiration_default_lead_days"), + KEY_VAULT_SECRET_REMINDER_DEFAULT_LEAD_DAYS, + 1, + 3650, + ) + settings["key_vault_secret_expiration_default_contact_email"] = _normalize_text( + settings.get("key_vault_secret_expiration_default_contact_email"), + 254, + ) + settings["key_vault_secret_expiration_require_expiration"] = _as_bool( + settings.get("key_vault_secret_expiration_require_expiration", False) + ) + settings["key_vault_secret_expiration_emit_contact_email_in_telemetry"] = _as_bool( + settings.get("key_vault_secret_expiration_emit_contact_email_in_telemetry", False) + ) + settings["key_vault_secret_expiration_admin_roles"] = normalize_admin_role_list( + settings.get("key_vault_secret_expiration_admin_roles") + ) + settings["key_vault_secret_expiration_scan_interval_seconds"] = _safe_int( + settings.get("key_vault_secret_expiration_scan_interval_seconds"), + KEY_VAULT_SECRET_REMINDER_DEFAULT_SCAN_INTERVAL_SECONDS, + 900, + 86400, + ) + return settings + + +def normalize_admin_role_list(value: Any) -> List[str]: + """Return distinct admin notification role names.""" + if isinstance(value, list): + raw_roles = value + elif isinstance(value, str): + raw_roles = value.replace(";", ",").split(",") + else: + raw_roles = [] + + roles = [] + for role in raw_roles: + normalized_role = _normalize_text(role, 80) + if normalized_role and normalized_role not in roles: + roles.append(normalized_role) + return roles or ["Admin"] + + +def normalize_key_vault_secret_reminder_config( + reminder_config: Dict[str, Any], + settings: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Validate and normalize a single per-secret reminder configuration.""" + settings = normalize_key_vault_reminder_settings(dict(settings or get_settings() or {})) + raw_config = reminder_config if isinstance(reminder_config, dict) else {} + enabled = _as_bool(raw_config.get("enabled")) + if not enabled: + return {"enabled": False} + + expiration_date = _parse_expiration_date( + raw_config.get("expires_on") or raw_config.get("expiration_date") + ) + if expiration_date is None: + raise ValueError("Expiration date is required when Key Vault expiration reminders are enabled.") + + contact_email = _normalize_text( + raw_config.get("contact_email") + or raw_config.get("reminder_email") + or settings.get("key_vault_secret_expiration_default_contact_email"), + 254, + ) + if not contact_email or "@" not in contact_email: + raise ValueError("A valid reminder email is required when Key Vault expiration reminders are enabled.") + + lead_days = _safe_int( + raw_config.get("lead_days"), + settings.get("key_vault_secret_expiration_default_lead_days", KEY_VAULT_SECRET_REMINDER_DEFAULT_LEAD_DAYS), + 1, + 3650, + ) + + return { + "enabled": True, + "expires_on": expiration_date.isoformat(), + "lead_days": lead_days, + "contact_email": contact_email, + "label": _normalize_text(raw_config.get("label") or raw_config.get("friendly_label"), 160), + "notes": _normalize_text(raw_config.get("notes") or raw_config.get("rotation_notes"), 1000), + } + + +def resolve_key_vault_secret_reminder_config( + owner_document: Dict[str, Any], + field_path: Tuple[str, ...], + settings: Optional[Dict[str, Any]] = None, +) -> Optional[Dict[str, Any]]: + """Return the normalized reminder config for a secret field, if configured.""" + if not isinstance(owner_document, dict): + return None + + metadata = owner_document.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + + raw_reminders = metadata.get(KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD) + if not isinstance(raw_reminders, dict): + return None + + field_key = ".".join(str(part) for part in field_path) + if field_key in raw_reminders: + raw_config = raw_reminders.get(field_key) + elif KEY_VAULT_SECRET_REMINDER_ALL_FIELDS in raw_reminders: + raw_config = raw_reminders.get(KEY_VAULT_SECRET_REMINDER_ALL_FIELDS) + else: + return None + + if isinstance(raw_config, bool): + raw_config = {"enabled": raw_config} + if not isinstance(raw_config, dict): + return None + + return normalize_key_vault_secret_reminder_config(raw_config, settings=settings) + + +def upsert_key_vault_secret_reminder( + secret_name: str, + reminder_config: Dict[str, Any], + context: Dict[str, Any], + key_vault_sync_status: str = KEY_VAULT_SECRET_REMINDER_SYNCED_STATUS, + key_vault_sync_error: str = "", +) -> Dict[str, Any]: + """Create or update the SimpleChat inventory document for a tracked secret.""" + if not reminder_config.get("enabled"): + return mark_key_vault_secret_reminder_disabled(secret_name, context=context) + + normalized_secret_name = _normalize_text(secret_name, 127) + if not normalized_secret_name: + raise ValueError("Secret name is required for a Key Vault reminder inventory entry.") + + scope = _normalize_text(context.get("scope"), 50) + scope_value = _normalize_text(context.get("scope_value"), 255) + if not scope or not scope_value: + raise ValueError("Reminder inventory context requires a scope and scope value.") + + reminder_id = build_key_vault_secret_reminder_id(normalized_secret_name) + scope_key = _format_scope_key(scope, scope_value) + existing = None + try: + existing = cosmos_key_vault_secret_reminders_container.read_item( + item=reminder_id, + partition_key=scope_key, + ) + except CosmosResourceNotFoundError: + existing = None + + expires_on = reminder_config["expires_on"] + notify_on = ( + _parse_expiration_date(expires_on) + - timedelta(days=int(reminder_config.get("lead_days") or KEY_VAULT_SECRET_REMINDER_DEFAULT_LEAD_DAYS)) + ).isoformat() + + now = _now_iso() + status = ( + KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS + if key_vault_sync_status == KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS + else KEY_VAULT_SECRET_REMINDER_ACTIVE_STATUS + ) + document = { + "id": reminder_id, + "type": "key_vault_secret_reminder", + "enabled": True, + "status": status, + "secret_name": normalized_secret_name, + "key_vault_name": _normalize_text(context.get("key_vault_name"), 120), + "scope": scope, + "scope_value": scope_value, + "scope_key": scope_key, + "source": _normalize_text(context.get("source"), 80), + "source_type": _normalize_text(context.get("source_type"), 80), + "source_id": _normalize_text(context.get("source_id"), 255), + "source_name": _normalize_text(context.get("source_name"), 255), + "source_display_name": _normalize_text(context.get("source_display_name"), 255), + "field_path": _normalize_text(context.get("field_path"), 255), + "field_label": _normalize_text(context.get("field_label"), 255), + "owner_user_id": _normalize_text(context.get("owner_user_id"), 255), + "group_id": _normalize_text(context.get("group_id"), 255), + "public_workspace_id": _normalize_text(context.get("public_workspace_id"), 255), + "configured_by": _normalize_text(context.get("configured_by"), 255), + "contact_email": reminder_config["contact_email"], + "label": reminder_config.get("label") or _normalize_text(context.get("field_label"), 160), + "notes": reminder_config.get("notes", ""), + "expires_on": expires_on, + "lead_days": int(reminder_config.get("lead_days") or KEY_VAULT_SECRET_REMINDER_DEFAULT_LEAD_DAYS), + "notify_on": notify_on, + "remediation_url": _normalize_text(context.get("remediation_url"), 500), + "key_vault_sync_status": key_vault_sync_status, + "key_vault_sync_error": _normalize_text(key_vault_sync_error, 1000), + "created_at": (existing or {}).get("created_at") or now, + "updated_at": now, + "last_notified_at": (existing or {}).get("last_notified_at"), + "last_notification_window_key": (existing or {}).get("last_notification_window_key"), + } + cosmos_key_vault_secret_reminders_container.upsert_item(document) + return document + + +def mark_key_vault_secret_reminder_disabled( + secret_name: str, + context: Optional[Dict[str, Any]] = None, + reason: str = "disabled", +) -> Dict[str, Any]: + """Disable an inventory entry when a user turns off tracking for a secret.""" + normalized_secret_name = _normalize_text(secret_name, 127) + if not normalized_secret_name: + return {} + + reminder_id = build_key_vault_secret_reminder_id(normalized_secret_name) + context = context or {} + scope = _normalize_text(context.get("scope"), 50) + scope_value = _normalize_text(context.get("scope_value"), 255) + scope_key = _format_scope_key(scope, scope_value) if scope and scope_value else None + candidates = [] + + if scope_key: + try: + candidates.append( + cosmos_key_vault_secret_reminders_container.read_item( + item=reminder_id, + partition_key=scope_key, + ) + ) + except CosmosResourceNotFoundError: + candidates = [] + else: + candidates = list( + cosmos_key_vault_secret_reminders_container.query_items( + query="SELECT * FROM c WHERE c.id = @id", + parameters=[{"name": "@id", "value": reminder_id}], + enable_cross_partition_query=True, + ) + ) + + if not candidates: + return {} + + updated_document = {} + for document in candidates: + document["enabled"] = False + document["status"] = KEY_VAULT_SECRET_REMINDER_DISABLED_STATUS + document["disabled_reason"] = _normalize_text(reason, 200) + document["updated_at"] = _now_iso() + cosmos_key_vault_secret_reminders_container.upsert_item(document) + updated_document = document + return updated_document + + +def sanitize_key_vault_secret_reminder(document: Dict[str, Any]) -> Dict[str, Any]: + """Return the admin-safe inventory projection for a reminder document.""" + sanitized = { + key: value + for key, value in (document or {}).items() + if not str(key).startswith("_") + } + expires_on = _parse_expiration_date(sanitized.get("expires_on")) + sanitized["days_until_expiry"] = ( + (expires_on - datetime.now(timezone.utc).date()).days + if expires_on + else None + ) + return sanitized + + +def list_key_vault_secret_reminders( + status: str = "", + scope: str = "", + source_type: str = "", + search: str = "", + limit: int = 250, +) -> List[Dict[str, Any]]: + """List Key Vault reminder inventory entries for the admin dashboard.""" + limit = _safe_int(limit, 250, 1, 1000) + query_parts = ["SELECT * FROM c WHERE c.type = @type"] + parameters = [{"name": "@type", "value": "key_vault_secret_reminder"}] + + if status: + query_parts.append("AND c.status = @status") + parameters.append({"name": "@status", "value": status}) + if scope: + query_parts.append("AND c.scope = @scope") + parameters.append({"name": "@scope", "value": scope}) + if source_type: + query_parts.append("AND c.source_type = @source_type") + parameters.append({"name": "@source_type", "value": source_type}) + + query_parts.append("ORDER BY c.expires_on ASC") + reminders = list( + cosmos_key_vault_secret_reminders_container.query_items( + query=" ".join(query_parts), + parameters=parameters, + enable_cross_partition_query=True, + ) + ) + + normalized_search = str(search or "").strip().lower() + if normalized_search: + searchable_fields = ( + "id", + "secret_name", + "source_name", + "source_display_name", + "field_path", + "field_label", + "contact_email", + "label", + "scope_value", + ) + reminders = [ + reminder + for reminder in reminders + if any(normalized_search in str(reminder.get(field) or "").lower() for field in searchable_fields) + ] + + return [sanitize_key_vault_secret_reminder(reminder) for reminder in reminders[:limit]] + + +def _build_notification_message(reminder: Dict[str, Any], days_until_expiry: int) -> str: + display_name = ( + reminder.get("label") + or reminder.get("source_display_name") + or reminder.get("source_name") + or reminder.get("secret_name") + ) + if days_until_expiry < 0: + return f"Key Vault secret '{display_name}' expired on {reminder.get('expires_on')}." + if days_until_expiry == 0: + return f"Key Vault secret '{display_name}' expires today." + return f"Key Vault secret '{display_name}' expires in {days_until_expiry} days." + + +def _create_expiration_notification(reminder: Dict[str, Any], settings: Dict[str, Any], days_until_expiry: int) -> Optional[Dict[str, Any]]: + title = "Key Vault secret expiration reminder" + message = _build_notification_message(reminder, days_until_expiry) + link_url = reminder.get("remediation_url") or "/admin/settings?tab=security#keyvault-section" + metadata = { + "reminder_id": reminder.get("id"), + "secret_name": reminder.get("secret_name"), + "expires_on": reminder.get("expires_on"), + "days_until_expiry": days_until_expiry, + "scope": reminder.get("scope"), + "scope_value": reminder.get("scope_value"), + "source_type": reminder.get("source_type"), + "source_id": reminder.get("source_id"), + "field_path": reminder.get("field_path"), + } + + scope = reminder.get("scope") + if scope == "user": + user_id = reminder.get("owner_user_id") or reminder.get("scope_value") + return create_notification( + user_id=user_id, + notification_type=KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE, + title=title, + message=message, + link_url=link_url, + metadata=metadata, + ) + if scope == "group": + return create_group_notification( + reminder.get("group_id") or reminder.get("scope_value"), + KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE, + title, + message, + link_url=link_url, + metadata=metadata, + ) + if scope == "public": + return create_public_workspace_notification( + reminder.get("public_workspace_id") or reminder.get("scope_value"), + KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE, + title, + message, + link_url=link_url, + metadata=metadata, + ) + + return create_notification( + notification_type=KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE, + title=title, + message=message, + link_url=link_url, + metadata=metadata, + assignment={ + "roles": normalize_admin_role_list(settings.get("key_vault_secret_expiration_admin_roles")), + }, + ) + + +def _emit_external_expiration_notification_event( + reminder: Dict[str, Any], + notification: Dict[str, Any], + days_until_expiry: int, + settings: Dict[str, Any], +) -> None: + """Emit a safe Azure Monitor event for external alert rules and automation.""" + event_extra = { + "reminder_id": reminder.get("id"), + "reminder_status": reminder.get("status"), + "scope": reminder.get("scope"), + "source": reminder.get("source"), + "source_type": reminder.get("source_type"), + "field_path": reminder.get("field_path"), + "key_vault_sync_status": reminder.get("key_vault_sync_status"), + "notification_id": notification.get("id"), + "notification_scope": notification.get("scope"), + "days_until_expiry": days_until_expiry, + "lead_days": reminder.get("lead_days"), + "expires_on": reminder.get("expires_on"), + "notify_on": reminder.get("notify_on"), + "scope_value_hash": _hash_external_telemetry_value(reminder.get("scope_value")), + "source_id_hash": _hash_external_telemetry_value(reminder.get("source_id")), + "owner_user_id_hash": _hash_external_telemetry_value(reminder.get("owner_user_id")), + "group_id_hash": _hash_external_telemetry_value(reminder.get("group_id")), + "public_workspace_id_hash": _hash_external_telemetry_value(reminder.get("public_workspace_id")), + "contact_email_hash": _hash_external_telemetry_value(reminder.get("contact_email")), + } + allowed_sensitive_dimensions = () + if settings.get("key_vault_secret_expiration_emit_contact_email_in_telemetry"): + event_extra["contact_email"] = reminder.get("contact_email") + allowed_sensitive_dimensions = ("contact_email",) + + log_external_event( + KEY_VAULT_REMINDER_EXTERNAL_EVENT_NAME, + extra=event_extra, + allowed_sensitive_dimensions=allowed_sensitive_dimensions, + ) + + +def check_due_key_vault_secret_reminders_once( + settings: Optional[Dict[str, Any]] = None, + now: Optional[datetime] = None, + limit: int = 1000, +) -> Dict[str, Any]: + """Send in-app notifications for active reminders that are in their lead window.""" + settings = normalize_key_vault_reminder_settings(dict(settings or get_settings() or {})) + if not settings.get("enable_key_vault_secret_expiration_reminders"): + return {"enabled": False, "checked": 0, "notifications_created": 0} + + current_date = (now or datetime.now(timezone.utc)).date() + active_reminders = list_key_vault_secret_reminders(limit=limit) + + checked = 0 + notifications_created = 0 + for reminder in active_reminders: + if reminder.get("status") not in { + KEY_VAULT_SECRET_REMINDER_ACTIVE_STATUS, + KEY_VAULT_SECRET_REMINDER_SYNC_FAILED_STATUS, + }: + continue + checked += 1 + expires_on = _parse_expiration_date(reminder.get("expires_on")) + if not expires_on: + continue + + lead_days = _safe_int( + reminder.get("lead_days"), + settings.get("key_vault_secret_expiration_default_lead_days", KEY_VAULT_SECRET_REMINDER_DEFAULT_LEAD_DAYS), + 1, + 3650, + ) + notify_on = expires_on - timedelta(days=lead_days) + if current_date < notify_on: + continue + + window_key = f"{expires_on.isoformat()}:{lead_days}" + if reminder.get("last_notification_window_key") == window_key: + continue + + days_until_expiry = (expires_on - current_date).days + notification = _create_expiration_notification(reminder, settings, days_until_expiry) + if not notification: + continue + + _emit_external_expiration_notification_event(reminder, notification, days_until_expiry, settings) + reminder["last_notified_at"] = _now_iso() + reminder["last_notification_window_key"] = window_key + reminder["updated_at"] = _now_iso() + cosmos_key_vault_secret_reminders_container.upsert_item(reminder) + notifications_created += 1 + + log_event( + "[KeyVaultReminders] Reminder sweep completed.", + extra={"checked": checked, "notifications_created": notifications_created}, + level=logging.INFO, + ) + return { + "enabled": True, + "checked": checked, + "notifications_created": notifications_created, + } diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py index 8b7e13764..d326ef8fd 100644 --- a/application/single_app/functions_notifications.py +++ b/application/single_app/functions_notifications.py @@ -27,6 +27,7 @@ TTL_60_DAYS = 60 * 24 * 60 * 60 # 60 days in seconds (5184000) ASSIGNMENT_NOTIFICATIONS_PARTITION_KEY = 'assignment-notifications' WORKFLOW_ALERT_NOTIFICATION_TYPE = 'workflow_priority_alert' +KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE = 'key_vault_secret_expiring' WORKFLOW_ALERT_PRIORITY_CONFIG = { 'low': { 'icon': 'bi-bell', @@ -163,6 +164,10 @@ WORKFLOW_ALERT_NOTIFICATION_TYPE: { 'icon': 'bi-bell', 'color': 'secondary' + }, + KEY_VAULT_SECRET_REMINDER_NOTIFICATION_TYPE: { + 'icon': 'bi-safe', + 'color': 'warning' } } diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 85c517f28..0ef7dbb22 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -193,6 +193,81 @@ def normalize_document_access_index_required_settings(settings): return changed +def _normalize_key_vault_admin_roles(value): + if isinstance(value, list): + raw_roles = value + elif isinstance(value, str): + raw_roles = value.replace(";", ",").split(",") + else: + raw_roles = [] + + roles = [] + for role in raw_roles: + normalized_role = str(role or "").strip()[:80] + if normalized_role and normalized_role not in roles: + roles.append(normalized_role) + return roles or ["Admin"] + + +def _normalize_key_vault_reminder_int(value, default_value, minimum, maximum): + try: + parsed_value = int(value) + except (TypeError, ValueError): + parsed_value = default_value + return min(max(parsed_value, minimum), maximum) + + +def _normalize_key_vault_reminder_bool(value): + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def normalize_key_vault_reminder_settings(settings): + """Normalize stored Key Vault expiration reminder settings in-place.""" + if not isinstance(settings, dict): + return False + + normalized_values = { + "enable_key_vault_secret_expiration_reminders": _normalize_key_vault_reminder_bool( + settings.get("enable_key_vault_secret_expiration_reminders", False) + ), + "key_vault_secret_expiration_default_lead_days": _normalize_key_vault_reminder_int( + settings.get("key_vault_secret_expiration_default_lead_days"), + 30, + 1, + 3650, + ), + "key_vault_secret_expiration_default_contact_email": str( + settings.get("key_vault_secret_expiration_default_contact_email") or "" + ).strip()[:254], + "key_vault_secret_expiration_require_expiration": _normalize_key_vault_reminder_bool( + settings.get("key_vault_secret_expiration_require_expiration", False) + ), + "key_vault_secret_expiration_emit_contact_email_in_telemetry": _normalize_key_vault_reminder_bool( + settings.get("key_vault_secret_expiration_emit_contact_email_in_telemetry", False) + ), + "key_vault_secret_expiration_admin_roles": _normalize_key_vault_admin_roles( + settings.get("key_vault_secret_expiration_admin_roles") + ), + "key_vault_secret_expiration_scan_interval_seconds": _normalize_key_vault_reminder_int( + settings.get("key_vault_secret_expiration_scan_interval_seconds"), + 21600, + 900, + 86400, + ), + } + + changed = False + for key, normalized_value in normalized_values.items(): + if settings.get(key) != normalized_value: + settings[key] = normalized_value + changed = True + return changed + + def redact_admin_settings_secrets_for_form(settings): redacted_settings = copy.deepcopy(settings or {}) for field_name in ADMIN_SETTINGS_FORM_SECRET_FIELDS: @@ -1435,6 +1510,13 @@ def get_settings(use_cosmos=False, include_source=False): 'enable_key_vault_secret_storage': False, 'key_vault_name': '', 'key_vault_identity': '', + 'enable_key_vault_secret_expiration_reminders': False, + 'key_vault_secret_expiration_default_lead_days': 30, + 'key_vault_secret_expiration_default_contact_email': '', + 'key_vault_secret_expiration_require_expiration': False, + 'key_vault_secret_expiration_emit_contact_email_in_telemetry': False, + 'key_vault_secret_expiration_admin_roles': ['Admin'], + 'key_vault_secret_expiration_scan_interval_seconds': 21600, # Retention Policy Settings 'enable_retention_policy_personal': False, @@ -1566,6 +1648,7 @@ def _format_result(settings_payload, source): document_access_index_settings_updated = normalize_document_access_index_required_settings(merged) inbound_mcp_settings_updated = normalize_inbound_mcp_settings(merged) public_workspace_display_settings_updated = normalize_public_workspace_display_settings(merged) + key_vault_reminder_settings_updated = normalize_key_vault_reminder_settings(merged) merged['enable_tabular_processing_plugin'] = is_tabular_processing_enabled(merged) @@ -1579,6 +1662,7 @@ def _format_result(settings_payload, source): or document_access_index_settings_updated or inbound_mcp_settings_updated or public_workspace_display_settings_updated + or key_vault_reminder_settings_updated ): cosmos_settings_container.upsert_item(merged) _refresh_app_settings_cache_after_write(merged, context="merge_upsert") @@ -1631,6 +1715,7 @@ def update_settings(new_settings): normalize_document_access_index_required_settings(settings_item) normalize_inbound_mcp_settings(settings_item) normalize_public_workspace_display_settings(settings_item) + normalize_key_vault_reminder_settings(settings_item) settings_item['enable_multi_model_endpoints'] = coerce_multi_model_endpoint_enablement( existing_multi_endpoint_enabled, settings_item.get('enable_multi_model_endpoints', False), diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index bc5e736c1..c21dd5d50 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -125,6 +125,13 @@ ) +ACTION_VALIDATION_ERROR_MESSAGE = "Invalid action configuration." +ACTION_PERMISSION_ERROR_MESSAGE = "You are not authorized to save this action." +ACTION_KEY_VAULT_ERROR_MESSAGE = "Unable to store action secrets in Key Vault." +PLUGIN_VALIDATION_ERROR_MESSAGE = "Invalid plugin configuration." +PLUGIN_KEY_VAULT_ERROR_MESSAGE = "Unable to store plugin secrets in Key Vault." + + DOCUMENT_SEARCH_INTERNAL_ENDPOINT = 'internal://document-search' @@ -957,10 +964,13 @@ def set_user_plugins(): except ValueError as e: debug_print(f"Validation error saving personal actions for user {user_id}: {e}") - return jsonify({'error': str(e)}), 400 + return jsonify({'error': ACTION_VALIDATION_ERROR_MESSAGE}), 400 except PermissionError as e: debug_print(f"Governance denied saving personal actions for user {user_id}: {e}") - return jsonify({'error': str(e)}), 403 + return jsonify({'error': ACTION_PERMISSION_ERROR_MESSAGE}), 403 + except RuntimeError as e: + debug_print(f"Key Vault error saving personal actions for user {user_id}: {e}") + return jsonify({'error': ACTION_KEY_VAULT_ERROR_MESSAGE}), 500 except Exception as e: debug_print(f"Error saving personal actions for user {user_id}: {e}") return jsonify({'error': 'Failed to save plugins'}), 500 @@ -1145,8 +1155,15 @@ def create_group_action_route(): try: saved = save_group_action(active_group, payload, user_id=user_id) + except ValueError as exc: + debug_print('Validation error saving group action: %s', exc) + return jsonify({'error': ACTION_VALIDATION_ERROR_MESSAGE}), 400 except PermissionError as exc: - return jsonify({'error': str(exc)}), 403 + debug_print('Permission denied saving group action: %s', exc) + return jsonify({'error': ACTION_PERMISSION_ERROR_MESSAGE}), 403 + except RuntimeError as exc: + debug_print('Key Vault error saving group action: %s', exc) + return jsonify({'error': ACTION_KEY_VAULT_ERROR_MESSAGE}), 500 except Exception as exc: debug_print('Failed to save group action: %s', exc) return jsonify({'error': 'Unable to save action'}), 500 @@ -1240,8 +1257,15 @@ def update_group_action_route(action_id): try: saved = save_group_action(active_group, merged, user_id=user_id) + except ValueError as exc: + debug_print('Validation error updating group action %s: %s', action_id, exc) + return jsonify({'error': ACTION_VALIDATION_ERROR_MESSAGE}), 400 except PermissionError as exc: - return jsonify({'error': str(exc)}), 403 + debug_print('Permission denied updating group action %s: %s', action_id, exc) + return jsonify({'error': ACTION_PERMISSION_ERROR_MESSAGE}), 403 + except RuntimeError as exc: + debug_print('Key Vault error updating group action %s: %s', action_id, exc) + return jsonify({'error': ACTION_KEY_VAULT_ERROR_MESSAGE}), 500 except Exception as exc: debug_print('Failed to update group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to update action'}), 500 @@ -1548,6 +1572,12 @@ def add_plugin(): # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) + except ValueError as e: + log_event(f"Validation error adding plugin: {e}", level=logging.WARNING) + return jsonify({'error': PLUGIN_VALIDATION_ERROR_MESSAGE}), 400 + except RuntimeError as e: + log_event(f"Key Vault error adding plugin: {e}", level=logging.ERROR) + return jsonify({'error': PLUGIN_KEY_VAULT_ERROR_MESSAGE}), 500 except Exception as e: log_event(f"Error adding plugin: {e}", level=logging.ERROR) return jsonify({'error': 'Failed to add plugin.'}), 500 @@ -1651,6 +1681,12 @@ def edit_plugin(plugin_name): log_event("Edit plugin failed: not found", level=logging.WARNING, extra={"action": "edit", "plugin_name": plugin_name}) return jsonify({'error': 'Plugin not found.'}), 404 + except ValueError as e: + log_event(f"Validation error editing plugin: {e}", level=logging.WARNING) + return jsonify({'error': PLUGIN_VALIDATION_ERROR_MESSAGE}), 400 + except RuntimeError as e: + log_event(f"Key Vault error editing plugin: {e}", level=logging.ERROR) + return jsonify({'error': PLUGIN_KEY_VAULT_ERROR_MESSAGE}), 500 except Exception as e: log_event(f"Error editing plugin: {e}", level=logging.ERROR) return jsonify({'error': 'Failed to edit plugin.'}), 500 diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 5d20fa3ab..1ec8e66c8 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -29,6 +29,10 @@ set_database_throughput, ) from functions_app_maintenance import get_app_maintenance_status, run_app_maintenance_once +from functions_keyvault_reminders import ( + check_due_key_vault_secret_reminders_once, + list_key_vault_secret_reminders, +) from functions_redis_monitoring import ( get_redis_explorer_keys, get_redis_explorer_value, @@ -201,6 +205,48 @@ def auto_fix_index_fields(idx_type: str, user_id: str = 'system', admin_email: s def register_route_backend_settings(bp): + @bp.route('/api/admin/settings/key-vault/secret-reminders', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def list_key_vault_secret_reminders_admin(): + """Return Key Vault secret expiration reminder inventory for admins.""" + try: + reminders = list_key_vault_secret_reminders( + status=request.args.get('status', ''), + scope=request.args.get('scope', ''), + source_type=request.args.get('source_type', ''), + search=request.args.get('search', ''), + limit=request.args.get('limit', 250), + ) + return jsonify({'success': True, 'reminders': reminders}), 200 + except Exception as exc: + log_event( + '[KeyVaultReminders] Failed to load admin inventory.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to load Key Vault reminder inventory.'}), 500 + + @bp.route('/api/admin/settings/key-vault/secret-reminders/run', methods=['POST']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def run_key_vault_secret_reminders_admin(): + """Run the Key Vault expiration reminder sweep on demand.""" + try: + result = check_due_key_vault_secret_reminders_once(settings=get_settings()) + return jsonify({'success': True, 'result': result}), 200 + except Exception as exc: + log_event( + '[KeyVaultReminders] Failed to run admin-triggered reminder sweep.', + extra={'error': str(exc)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({'error': 'Failed to run Key Vault reminder sweep.'}), 500 + @bp.route('/api/admin/settings/app-maintenance/status', methods=['GET']) @swagger_route(security=get_auth_security()) @login_required diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 91018f147..451d964a2 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -880,6 +880,7 @@ def admin_settings(): settings['key_vault_name'] = '' if 'key_vault_identity' not in settings: settings['key_vault_identity'] = '' + normalize_key_vault_reminder_settings(settings) # --- Add defaults for left nav --- if 'enable_left_nav_default' not in settings: @@ -2675,6 +2676,13 @@ def is_valid_url(url): 'enable_key_vault_secret_storage': form_data.get('enable_key_vault_secret_storage') == 'on', 'key_vault_name': form_data.get('key_vault_name', '').strip(), 'key_vault_identity': form_data.get('key_vault_identity', ''), + 'enable_key_vault_secret_expiration_reminders': form_data.get('enable_key_vault_secret_expiration_reminders') == 'on', + 'key_vault_secret_expiration_default_lead_days': form_data.get('key_vault_secret_expiration_default_lead_days', '30'), + 'key_vault_secret_expiration_default_contact_email': form_data.get('key_vault_secret_expiration_default_contact_email', '').strip(), + 'key_vault_secret_expiration_require_expiration': form_data.get('key_vault_secret_expiration_require_expiration') == 'on', + 'key_vault_secret_expiration_emit_contact_email_in_telemetry': form_data.get('key_vault_secret_expiration_emit_contact_email_in_telemetry') == 'on', + 'key_vault_secret_expiration_admin_roles': form_data.get('key_vault_secret_expiration_admin_roles', 'Admin'), + 'key_vault_secret_expiration_scan_interval_seconds': form_data.get('key_vault_secret_expiration_scan_interval_seconds', '21600'), # Authentication & Redirect Settings 'enable_front_door': enable_front_door, diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 90e9dd17b..900698f2e 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -4917,6 +4917,143 @@ function setupFileDownloadAssignments() { }).setup(); } +function setKeyVaultReminderStatus(message, variant = 'info') { + const statusElement = document.getElementById('key-vault-reminders-status-message'); + if (!statusElement) { + return; + } + + statusElement.textContent = message || ''; + statusElement.className = `alert alert-${variant}${message ? '' : ' d-none'}`; +} + +function appendKeyVaultReminderCell(row, text, className = '') { + const cell = document.createElement('td'); + if (className) { + cell.className = className; + } + cell.textContent = text || ''; + row.appendChild(cell); +} + +function formatKeyVaultReminderExpiry(reminder) { + const expiresOn = reminder.expires_on || 'Unknown'; + if (typeof reminder.days_until_expiry !== 'number') { + return expiresOn; + } + if (reminder.days_until_expiry < 0) { + return `${expiresOn} (${Math.abs(reminder.days_until_expiry)} days expired)`; + } + if (reminder.days_until_expiry === 0) { + return `${expiresOn} (today)`; + } + return `${expiresOn} (${reminder.days_until_expiry} days)`; +} + +function renderKeyVaultReminderInventory(reminders) { + const tableBody = document.getElementById('key-vault-reminders-table-body'); + if (!tableBody) { + return; + } + + tableBody.replaceChildren(); + if (!Array.isArray(reminders) || reminders.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 8; + cell.className = 'text-muted'; + cell.textContent = 'No Key Vault reminder inventory entries match the current filters.'; + row.appendChild(cell); + tableBody.appendChild(row); + return; + } + + reminders.forEach(reminder => { + const row = document.createElement('tr'); + const sourceLabel = reminder.source_display_name || reminder.source_name || reminder.source_id || ''; + const fieldLabel = reminder.field_label || reminder.field_path || ''; + const statusLabel = reminder.key_vault_sync_status === 'sync_failed' + ? `${reminder.status || 'sync_failed'}: ${reminder.key_vault_sync_error || 'Key Vault sync failed'}` + : (reminder.status || ''); + + appendKeyVaultReminderCell(row, formatKeyVaultReminderExpiry(reminder)); + appendKeyVaultReminderCell(row, `${reminder.scope || ''}: ${reminder.scope_value || ''}`); + appendKeyVaultReminderCell(row, sourceLabel); + appendKeyVaultReminderCell(row, fieldLabel); + appendKeyVaultReminderCell(row, reminder.contact_email || ''); + appendKeyVaultReminderCell(row, statusLabel); + appendKeyVaultReminderCell(row, reminder.id || '', 'font-monospace small'); + appendKeyVaultReminderCell(row, reminder.secret_name || '', 'font-monospace small'); + tableBody.appendChild(row); + }); +} + +async function loadKeyVaultReminderInventory() { + const refreshButton = document.getElementById('key-vault-reminders-refresh'); + const searchInput = document.getElementById('key-vault-reminders-search'); + const statusSelect = document.getElementById('key-vault-reminders-status'); + const params = new URLSearchParams(); + if (searchInput?.value.trim()) { + params.set('search', searchInput.value.trim()); + } + if (statusSelect?.value) { + params.set('status', statusSelect.value); + } + + setKeyVaultReminderStatus('Loading Key Vault reminder inventory...', 'info'); + if (refreshButton) { + refreshButton.disabled = true; + } + + try { + const response = await fetch(`/api/admin/settings/key-vault/secret-reminders?${params.toString()}`); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to load Key Vault reminder inventory.'); + } + renderKeyVaultReminderInventory(data.reminders || []); + setKeyVaultReminderStatus(`Loaded ${Array.isArray(data.reminders) ? data.reminders.length : 0} reminder entries.`, 'success'); + } catch (error) { + renderKeyVaultReminderInventory([]); + setKeyVaultReminderStatus(error.message || 'Failed to load Key Vault reminder inventory.', 'danger'); + } finally { + if (refreshButton) { + refreshButton.disabled = false; + } + } +} + +async function runKeyVaultReminderSweep() { + const runButton = document.getElementById('key-vault-reminders-run'); + setKeyVaultReminderStatus('Running Key Vault reminder sweep...', 'info'); + if (runButton) { + runButton.disabled = true; + } + + try { + const response = await fetch('/api/admin/settings/key-vault/secret-reminders/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to run Key Vault reminder sweep.'); + } + const result = data.result || {}; + setKeyVaultReminderStatus( + `Reminder sweep checked ${result.checked || 0} entries and created ${result.notifications_created || 0} notifications.`, + 'success' + ); + await loadKeyVaultReminderInventory(); + } catch (error) { + setKeyVaultReminderStatus(error.message || 'Failed to run Key Vault reminder sweep.', 'danger'); + } finally { + if (runButton) { + runButton.disabled = false; + } + } +} + document.addEventListener('DOMContentLoaded', () => { setupAdminFormAutofillMetadata(); @@ -6694,13 +6831,44 @@ function setupToggles() { const enableKeyVaultCheckbox = document.getElementById('enable_key_vault_secret_storage'); if (enableKeyVaultCheckbox) { + const keyVaultSettings = document.getElementById('key_vault_settings'); + if (keyVaultSettings) { + keyVaultSettings.classList.toggle('d-none', !enableKeyVaultCheckbox.checked); + } enableKeyVaultCheckbox.addEventListener('change', function() { - const keyVaultSettings = document.getElementById('key_vault_settings'); - keyVaultSettings.style.display = this.checked ? 'block' : 'none'; + if (keyVaultSettings) { + keyVaultSettings.classList.toggle('d-none', !this.checked); + } + markFormAsModified(); + }); + } + + const enableKeyVaultRemindersCheckbox = document.getElementById('enable_key_vault_secret_expiration_reminders'); + const keyVaultReminderSettings = document.getElementById('key_vault_expiration_reminder_settings'); + if (enableKeyVaultRemindersCheckbox && keyVaultReminderSettings) { + keyVaultReminderSettings.classList.toggle('d-none', !enableKeyVaultRemindersCheckbox.checked); + enableKeyVaultRemindersCheckbox.addEventListener('change', function() { + keyVaultReminderSettings.classList.toggle('d-none', !this.checked); markFormAsModified(); }); } + document.getElementById('key-vault-reminders-refresh')?.addEventListener('click', () => { + void loadKeyVaultReminderInventory(); + }); + document.getElementById('key-vault-reminders-run')?.addEventListener('click', () => { + void runKeyVaultReminderSweep(); + }); + document.getElementById('key-vault-reminders-search')?.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + void loadKeyVaultReminderInventory(); + } + }); + document.getElementById('key-vault-reminders-status')?.addEventListener('change', () => { + void loadKeyVaultReminderInventory(); + }); + const enableWebSearch = document.getElementById('enable_web_search'); const webSearchFoundrySettings = document.getElementById('web_search_foundry_settings'); const webSearchConsentInput = document.getElementById('web_search_consent_accepted'); diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index c4e4e1ed6..3ea7f6509 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -25,6 +25,8 @@ const TABLEAU_AUTH_METHOD_PAT = 'personal_access_token'; const TABLEAU_AUTH_METHOD_USERNAME_PASSWORD = 'username_password'; const publicWorkspacePlural = window.getPublicWorkspaceLabel ? window.getPublicWorkspaceLabel('plural') : 'Public Workspaces'; const MCP_PLUGIN_TYPE = 'mcp'; +const KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD = 'key_vault_secret_reminders'; +const KEY_VAULT_SECRET_REMINDER_ALL_FIELDS = '__all__'; const MCP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; const MCP_MAX_CUSTOM_HEADER_COUNT = 20; const MCP_MAX_HEADER_VALUE_LENGTH = 4096; @@ -706,6 +708,11 @@ export class PluginModalStepper { testCosmosBtn.addEventListener('click', () => this.testCosmosConnection()); } + const keyVaultReminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); + if (keyVaultReminderToggle) { + keyVaultReminderToggle.addEventListener('change', () => this.toggleKeyVaultReminderFields()); + } + // Set up display name to generated name conversion this.setupNameGeneration(); @@ -719,6 +726,145 @@ export class PluginModalStepper { }); } + toggleKeyVaultReminderFields() { + const reminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); + const reminderFields = document.getElementById('plugin-key-vault-reminder-fields'); + if (!reminderToggle || !reminderFields) { + return; + } + reminderFields.classList.toggle('d-none', !reminderToggle.checked); + } + + clearKeyVaultReminderForm() { + const reminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); + if (reminderToggle) { + reminderToggle.checked = false; + } + [ + 'plugin-key-vault-reminder-expires-on', + 'plugin-key-vault-reminder-email', + 'plugin-key-vault-reminder-label', + 'plugin-key-vault-reminder-notes' + ].forEach(id => { + const element = document.getElementById(id); + if (element) { + element.value = element.dataset.defaultValue || ''; + } + }); + const leadDays = document.getElementById('plugin-key-vault-reminder-lead-days'); + if (leadDays) { + leadDays.value = leadDays.dataset.defaultValue || leadDays.defaultValue || '30'; + } + this.toggleKeyVaultReminderFields(); + } + + getKeyVaultReminderAllConfig(metadata) { + const reminders = metadata && typeof metadata === 'object' + ? metadata[KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD] + : null; + if (!reminders || typeof reminders !== 'object') { + return null; + } + const allConfig = reminders[KEY_VAULT_SECRET_REMINDER_ALL_FIELDS]; + return allConfig && typeof allConfig === 'object' ? allConfig : null; + } + + populateKeyVaultReminderForm(metadata) { + this.clearKeyVaultReminderForm(); + const allConfig = this.getKeyVaultReminderAllConfig(metadata); + if (!allConfig || !allConfig.enabled) { + return; + } + + const reminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); + if (reminderToggle) { + reminderToggle.checked = true; + } + const expiresOn = document.getElementById('plugin-key-vault-reminder-expires-on'); + const email = document.getElementById('plugin-key-vault-reminder-email'); + const leadDays = document.getElementById('plugin-key-vault-reminder-lead-days'); + const label = document.getElementById('plugin-key-vault-reminder-label'); + const notes = document.getElementById('plugin-key-vault-reminder-notes'); + if (expiresOn) { + expiresOn.value = String(allConfig.expires_on || allConfig.expiration_date || '').slice(0, 10); + } + if (email) { + email.value = allConfig.contact_email || allConfig.reminder_email || ''; + } + if (leadDays) { + leadDays.value = allConfig.lead_days || '30'; + } + if (label) { + label.value = allConfig.label || allConfig.friendly_label || ''; + } + if (notes) { + notes.value = allConfig.notes || allConfig.rotation_notes || ''; + } + this.toggleKeyVaultReminderFields(); + } + + validateKeyVaultReminderFields() { + const reminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); + if (!reminderToggle?.checked) { + return true; + } + + const expiresOn = document.getElementById('plugin-key-vault-reminder-expires-on')?.value.trim(); + const email = document.getElementById('plugin-key-vault-reminder-email')?.value.trim(); + const leadDays = parseInt(document.getElementById('plugin-key-vault-reminder-lead-days')?.value || '30', 10); + if (!expiresOn) { + this.showError('Expiration date is required when Key Vault expiration tracking is enabled.'); + return false; + } + if (!email || !email.includes('@')) { + this.showError('A valid reminder email is required when Key Vault expiration tracking is enabled.'); + return false; + } + if (!Number.isInteger(leadDays) || leadDays < 1 || leadDays > 3650) { + this.showError('Lead days must be between 1 and 3650.'); + return false; + } + return true; + } + + applyKeyVaultReminderMetadata(metadata) { + const normalizedMetadata = metadata && typeof metadata === 'object' ? metadata : {}; + const reminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); + if (!reminderToggle) { + return normalizedMetadata; + } + + const existingReminders = normalizedMetadata[KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD]; + const hasExistingAllConfig = Boolean( + existingReminders + && typeof existingReminders === 'object' + && existingReminders[KEY_VAULT_SECRET_REMINDER_ALL_FIELDS] + ); + if (!reminderToggle.checked) { + if (hasExistingAllConfig) { + normalizedMetadata[KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD] = { + ...existingReminders, + [KEY_VAULT_SECRET_REMINDER_ALL_FIELDS]: { enabled: false } + }; + } + return normalizedMetadata; + } + + const leadDays = parseInt(document.getElementById('plugin-key-vault-reminder-lead-days')?.value || '30', 10); + normalizedMetadata[KEY_VAULT_SECRET_REMINDERS_METADATA_FIELD] = { + ...(existingReminders && typeof existingReminders === 'object' ? existingReminders : {}), + [KEY_VAULT_SECRET_REMINDER_ALL_FIELDS]: { + enabled: true, + expires_on: document.getElementById('plugin-key-vault-reminder-expires-on')?.value.trim() || '', + contact_email: document.getElementById('plugin-key-vault-reminder-email')?.value.trim() || '', + lead_days: Number.isInteger(leadDays) ? leadDays : 30, + label: document.getElementById('plugin-key-vault-reminder-label')?.value.trim() || '', + notes: document.getElementById('plugin-key-vault-reminder-notes')?.value.trim() || '' + } + }; + return normalizedMetadata; + } + async showModal(plugin = null) { this.isEditMode = !!plugin; this.selectedType = plugin?.type || null; @@ -3902,6 +4048,7 @@ export class PluginModalStepper { case 4: // Validate JSON fields if (!this.validateJSONField('plugin-metadata', 'Metadata')) return false; + if (!this.validateKeyVaultReminderFields()) return false; //if (!this.validateJSONField('plugin-additional-fields', 'Additional Fields')) return false; break; } @@ -4918,6 +5065,7 @@ export class PluginModalStepper { JSON.stringify(plugin.additionalFields, null, 2) : '{}'; document.getElementById('plugin-metadata').value = metadata; + this.populateKeyVaultReminderForm(plugin.metadata || {}); try { document.getElementById('plugin-additional-fields').value = additionalFields; } catch (e) { @@ -5276,6 +5424,7 @@ export class PluginModalStepper { try { const metadataValue = document.getElementById('plugin-metadata').value.trim(); metadata = metadataValue ? JSON.parse(metadataValue) : {}; + metadata = this.applyKeyVaultReminderMetadata(metadata); } catch (e) { throw new Error('Invalid metadata JSON'); } @@ -6593,6 +6742,7 @@ export class PluginModalStepper { this.blobStorageReadFileTypeState = this.getDefaultBlobStorageReadFileTypes(); this.blobStorageUploadFileTypeState = this.getDefaultBlobStorageUploadFileTypes(); this.renderBlobStorageConfiguration(); + this.clearKeyVaultReminderForm(); // Clear any type selection this.selectedType = null; diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 3306e5890..f93063be1 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -1250,6 +1250,56 @@
+ Applies the same expiration reminder metadata to every action secret stored in Key Vault, such as API keys, service principal secrets, passwords, connection strings, and custom secret fields. +
++ Track SimpleChat-owned Key Vault secrets with expiration dates, in-app reminders, and an admin inventory that maps opaque secret names back to their owner and source. +
+key_vault_expiration_reminder_triggered whenever a reminder notification is created.
+ Use an Azure Monitor scheduled query alert with an action group, Logic App, Function, or webhook to send external notifications. Enable the routing email option below if downstream automation needs to send directly to the configured reminder contact.
+ traces
+| where customDimensions.sc_event_name == 'key_vault_expiration_reminder_triggered'
+| project timestamp,
+ reminder_id = tostring(customDimensions.sc_event_reminder_id),
+ scope = tostring(customDimensions.sc_event_scope),
+ source_type = tostring(customDimensions.sc_event_source_type),
+ days_until_expiry = toint(customDimensions.sc_event_days_until_expiry),
+ expires_on = tostring(customDimensions.sc_event_expires_on),
+ contact_email = tostring(customDimensions.sc_event_contact_email)
+ The contact_email column is populated only when the opt-in below is enabled. For workspace-based Log Analytics queries, use the equivalent Application Insights traces table and dimensions/properties fields available in that workspace.
+ | Expires | +Scope | +Source | +Field | +Contact | +Status | +Reminder ID | +Secret | +
|---|---|---|---|---|---|---|---|
| Refresh inventory to load tracked secrets. | +|||||||