notifications
Some checks are pending
Docker Image - DockFlare / build_self_hosted (push) Waiting to run
Docker Image - DockFlare / build_github_hosted_fallback (push) Blocked by required conditions

This commit is contained in:
ChrispyBacon-dev 2026-09-01 09:26:47 +02:00
parent 96483198c4
commit 29d589b040
10 changed files with 321 additions and 56 deletions

1
.gitignore vendored
View file

@ -84,6 +84,7 @@ $RECYCLE.BIN/
.env
.claude
.docs
/dockflare/tests/
dockflare-agent/overview.json
dockflare-agent/data/
data/state.json

View file

@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Added
- **Apprise notifications:** Added opt-in, encrypted notification destinations for rule lifecycle, Cloudflare failures, Docker listener failures, Agent health, and tunnel health. Delivery uses a bounded background queue with cooldowns, startup and intentional-action suppression, redacted settings/status output, and authenticated test delivery. Addresses [#302](https://github.com/ChrispyBacon-dev/DockFlare/issues/302).
- **Apprise notifications:** Added opt-in, encrypted notification destinations for rule lifecycle, Cloudflare failures, Docker listener failures, Agent enrollment, decommission lifecycle, Agent health, tunnel health, and explicit Access Policy management. Event controls are grouped by area, with routine administrative events opt-in and failure/terminal observability enabled by default. Delivery uses a bounded background queue with cooldowns, startup and intentional-action suppression, redacted settings/status output, and authenticated test delivery. Addresses [#302](https://github.com/ChrispyBacon-dev/DockFlare/issues/302).
- **Safe Agent decommissioning:** Replaced immediate Agent deletion with a durable prepare, ownership-safe Cloudflare cleanup, and final Agent shutdown workflow. Decommissioned Agents persist a local tombstone, stop their managed tunnel without Docker delete permission, revoke their API key only after final acknowledgement, and cannot silently recreate the tunnel after restart. The Agents UI now shows retryable progress and provides separately acknowledged host cleanup commands that remove the Agent Docker deployment while preserving the shared external `cloudflare-net` network.
### Fixed

View file

@ -7,7 +7,7 @@ import re
import uuid
from datetime import datetime, timedelta, timezone
from app import tunnel_state
from app import config, tunnel_state
from app.core.access_manager import delete_cloudflare_access_application
from app.core.cloudflare_api import delete_cloudflare_dns_record, delete_tunnel_via_api
from app.core.state_manager import (
@ -21,6 +21,7 @@ from app.core.state_manager import (
state_lock,
)
from app.core.tunnel_manager import update_cloudflare_config
from app.core import notification_manager
TERMINAL_STATES = {"completed", "forced_completed"}
@ -42,6 +43,43 @@ def _bounded_int_env(name, default, minimum, maximum):
DEFAULT_TIMEOUT_SECONDS = _bounded_int_env("AGENT_DECOMMISSION_TIMEOUT_SECONDS", 180, 30, 1800)
def notification_context(operation):
plan = operation.get("resource_plan") or {}
results = operation.get("cleanup_results") or {}
return {
"agent_name": operation.get("display_name"),
"agent_id": str(operation.get("agent_id") or "")[:12],
"tunnel_id": str(plan.get("tunnel_id") or "")[:12],
"operation_id": str(operation.get("operation_id") or "")[:12],
"operation": "decommission",
"rules_count": results.get("rules_removed", len(plan.get("rule_keys") or [])),
"source": "admin",
"public_url": config.DOCKFLARE_PUBLIC_URL,
}
def notify_transition(operation, previous_state=None):
"""Emit a safe notification for a committed decommission state transition."""
if not isinstance(operation, dict):
return False
state = operation.get("state")
if state == previous_state:
return False
if state in TERMINAL_STATES:
event_type = "agent.decommission_completed"
elif state == "timed_out":
event_type = "agent.decommission_stalled"
elif state in {"prepare_failed", "cleanup_failed", "finalize_failed", "force_failed"}:
event_type = "agent.decommission_failed"
else:
return False
return notification_manager.emit(
event_type,
str(operation.get("operation_id") or operation.get("agent_id") or "unknown"),
notification_context(operation),
)
class DecommissionError(RuntimeError):
"""Stable decommission failure safe to map to an API response."""
@ -721,6 +759,8 @@ def expire_due_operations(now=None):
agents[agent_id].clear()
agents[agent_id].update(previous)
return []
for operation_id in changed:
notify_transition(get_operation(operation_id), "waiting")
return changed

View file

@ -21,8 +21,17 @@ EVENT_DEFINITIONS = {
"docker.listener_failure": ("docker_listener_failure", "failure", True),
"agent.offline": ("agent_offline", "failure", True),
"agent.online": ("agent_online", "success", True),
"agent.enrolled": ("agent_enrolled", "success", True),
"agent.enrollment_failed": ("agent_enrollment_failed", "failure", True),
"agent.decommission_started": ("agent_decommission_started", "warning", False),
"agent.decommission_completed": ("agent_decommission_completed", "success", True),
"agent.decommission_failed": ("agent_decommission_failed", "failure", True),
"agent.decommission_stalled": ("agent_decommission_stalled", "warning", True),
"tunnel.down": ("tunnel_down", "failure", True),
"tunnel.recovered": ("tunnel_recovered", "success", True),
"access.policy_created": ("access_policy_created", "success", False),
"access.policy_updated": ("access_policy_updated", "info", False),
"access.policy_deleted": ("access_policy_deleted", "info", False),
}
DEFAULT_NOTIFICATION_CONFIG = {
@ -38,7 +47,13 @@ BOOTSTRAP_SUPPRESSED_EVENTS = {
"rule.pending_deletion",
"rule.deleted",
"agent.online",
"agent.enrolled",
"agent.decommission_started",
"agent.decommission_completed",
"tunnel.recovered",
"access.policy_created",
"access.policy_updated",
"access.policy_deleted",
}
COOLDOWN_EVENTS = {
@ -47,6 +62,9 @@ COOLDOWN_EVENTS = {
"cloudflare.access_failure",
"docker.listener_failure",
"agent.offline",
"agent.enrollment_failed",
"agent.decommission_failed",
"agent.decommission_stalled",
"tunnel.down",
}
@ -66,8 +84,17 @@ TITLE_MAP = {
"docker.listener_failure": "❌ DockFlare — Docker listener stopped",
"agent.offline": "🔴 DockFlare — Agent offline",
"agent.online": "🟢 DockFlare — Agent recovered",
"agent.enrolled": "✅ DockFlare — Agent enrolled",
"agent.enrollment_failed": "❌ DockFlare — Agent enrollment failed",
"agent.decommission_started": "⚠️ DockFlare — Agent decommission started",
"agent.decommission_completed": "✅ DockFlare — Agent decommission completed",
"agent.decommission_failed": "❌ DockFlare — Agent decommission failed",
"agent.decommission_stalled": "⚠️ DockFlare — Agent decommission stalled",
"tunnel.down": "🔴 DockFlare — Tunnel down",
"tunnel.recovered": "🟢 DockFlare — Tunnel recovered",
"access.policy_created": "✅ DockFlare — Access Policy created",
"access.policy_updated": "ℹ️ DockFlare — Access Policy updated",
"access.policy_deleted": "ℹ️ DockFlare — Access Policy deleted",
"notification.test": "🔔 DockFlare — Test notification",
}
@ -76,6 +103,15 @@ STATUS_MAP = {
"agent.online": "Online",
"tunnel.down": "Down",
"tunnel.recovered": "Running",
"agent.enrolled": "Enrolled",
"agent.enrollment_failed": "Enrollment failed",
"agent.decommission_started": "In progress",
"agent.decommission_completed": "Completed",
"agent.decommission_failed": "Failed",
"agent.decommission_stalled": "Timed out",
"access.policy_created": "Created",
"access.policy_updated": "Updated",
"access.policy_deleted": "Deleted",
}
@ -520,6 +556,18 @@ class NotificationManager:
if context.get(key) not in (None, ""):
lines.append(f"{label}: {context[key]}")
if context.get("policy_name"):
lines.append(f"Access Policy: {context['policy_name']}")
if context.get("policy_type"):
lines.append(f"Policy type: {context['policy_type']}")
if context.get("identity_provider_count"):
lines.append(f"Identity providers: {context['identity_provider_count']}")
if context.get("rules_count"):
rules_label = "Managed rules" if event_type.startswith("agent.decommission_") else "Policy rules"
lines.append(f"{rules_label}: {context['rules_count']}")
if context.get("affected_service_count"):
lines.append(f"Affected services: {context['affected_service_count']}")
if context.get("delete_at"):
lines.append(f"Deletion deadline: {context['delete_at']}")
if context.get("grace_period_seconds"):
@ -533,7 +581,11 @@ class NotificationManager:
technical_fields = [
(label, context[key])
for key, label in (("container_id", "Container ID"), ("agent_id", "Agent ID"), ("tunnel_id", "Tunnel ID"))
for key, label in (
("container_id", "Container ID"), ("agent_id", "Agent ID"),
("tunnel_id", "Tunnel ID"), ("operation_id", "Operation ID"),
("policy_id", "Policy ID"),
)
if context.get(key) not in (None, "")
]
if technical_fields:
@ -560,7 +612,9 @@ class NotificationManager:
"message", "operation", "hostname", "path", "service", "source",
"container_name", "container_id", "agent_name", "agent_id",
"tunnel_name", "tunnel_id", "delete_at", "grace_period_seconds",
"retry_count", "resources", "public_url",
"retry_count", "resources", "public_url", "operation_id",
"policy_name", "policy_id", "policy_type", "identity_provider_count",
"rules_count", "affected_service_count",
}
sanitized = {}
for key in allowed:

View file

@ -432,7 +432,7 @@ def delete_access_group_and_policy(group_id):
logging.warning(f"Access group '{group_id}' not found in state")
return False
group = access_groups[group_id]
group = copy.deepcopy(access_groups[group_id])
policy_id = group.get("cloudflare_policy_id")
if policy_id:
@ -444,6 +444,9 @@ def delete_access_group_and_policy(group_id):
logging.info(f"No Cloudflare policy ID found for access group '{group_id}', removing from state only")
del access_groups[group_id]
save_state()
if not save_state():
access_groups[group_id] = group
logging.error(f"Failed to persist removal of access group '{group_id}'")
return False
logging.info(f"Removed access group '{group_id}' from state")
return True

View file

@ -177,6 +177,11 @@
"settings.notification_cooldown": "Failure cooldown (seconds)",
"settings.notification_cooldown_help": "Suppresses repeated alerts for the same incident for 60 to 86400 seconds.",
"settings.notification_events": "Events",
"settings.notification_group_rules": "Rule lifecycle",
"settings.notification_group_failures": "Cloudflare and system failures",
"settings.notification_group_agents": "Agent lifecycle",
"settings.notification_group_tunnels": "Tunnel health",
"settings.notification_group_access_policies": "Access Policy management",
"settings.notification_event_rule_activated": "Rule activated",
"settings.notification_event_rule_restored": "Rule restored",
"settings.notification_event_rule_pending_deletion": "Rule pending deletion",
@ -187,8 +192,17 @@
"settings.notification_event_docker_listener_failure": "Docker listener stopped",
"settings.notification_event_agent_offline": "Agent offline",
"settings.notification_event_agent_online": "Agent recovered",
"settings.notification_event_agent_enrolled": "Agent enrolled",
"settings.notification_event_agent_enrollment_failed": "Agent enrollment failed",
"settings.notification_event_agent_decommission_started": "Agent decommission started",
"settings.notification_event_agent_decommission_completed": "Agent decommission completed",
"settings.notification_event_agent_decommission_failed": "Agent decommission failed",
"settings.notification_event_agent_decommission_stalled": "Agent decommission timed out",
"settings.notification_event_tunnel_down": "Tunnel down",
"settings.notification_event_tunnel_recovered": "Tunnel recovered",
"settings.notification_event_access_policy_created": "Access Policy created",
"settings.notification_event_access_policy_updated": "Access Policy updated",
"settings.notification_event_access_policy_deleted": "Access Policy deleted",
"settings.save_notification_settings": "Save notification settings",
"settings.send_test_notification": "Send test notification",
"settings.notification_security_warning": "Apprise URLs often contain secrets and can make DockFlare connect to external or internal addresses. Configure only destinations you trust. Backups contain the encrypted configuration and its key and must be stored securely.",

View file

@ -507,22 +507,44 @@
<label class="label"><span class="label-text-alt">{{ t('settings.notification_cooldown_help') }}</span></label>
</div>
{% set notification_events = [
{% set notification_event_groups = [
('settings.notification_group_rules', [
('rule_activated', 'settings.notification_event_rule_activated'),
('rule_restored', 'settings.notification_event_rule_restored'),
('rule_pending_deletion', 'settings.notification_event_rule_pending_deletion'),
('rule_deleted', 'settings.notification_event_rule_deleted'),
('rule_deleted', 'settings.notification_event_rule_deleted')
]),
('settings.notification_group_failures', [
('cloudflare_tunnel_failure', 'settings.notification_event_tunnel_failure'),
('cloudflare_dns_failure', 'settings.notification_event_dns_failure'),
('cloudflare_access_failure', 'settings.notification_event_access_failure'),
('docker_listener_failure', 'settings.notification_event_docker_listener_failure'),
('docker_listener_failure', 'settings.notification_event_docker_listener_failure')
]),
('settings.notification_group_agents', [
('agent_enrolled', 'settings.notification_event_agent_enrolled'),
('agent_enrollment_failed', 'settings.notification_event_agent_enrollment_failed'),
('agent_offline', 'settings.notification_event_agent_offline'),
('agent_online', 'settings.notification_event_agent_online'),
('agent_decommission_started', 'settings.notification_event_agent_decommission_started'),
('agent_decommission_completed', 'settings.notification_event_agent_decommission_completed'),
('agent_decommission_failed', 'settings.notification_event_agent_decommission_failed'),
('agent_decommission_stalled', 'settings.notification_event_agent_decommission_stalled')
]),
('settings.notification_group_tunnels', [
('tunnel_down', 'settings.notification_event_tunnel_down'),
('tunnel_recovered', 'settings.notification_event_tunnel_recovered')
]),
('settings.notification_group_access_policies', [
('access_policy_created', 'settings.notification_event_access_policy_created'),
('access_policy_updated', 'settings.notification_event_access_policy_updated'),
('access_policy_deleted', 'settings.notification_event_access_policy_deleted')
])
] %}
<fieldset>
<fieldset class="space-y-4">
<legend class="font-semibold mb-3">{{ t('settings.notification_events') }}</legend>
{% for group_label_key, notification_events in notification_event_groups %}
<div>
<h4 class="text-sm font-semibold mb-2">{{ t(group_label_key) }}</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{% for field_name, label_key in notification_events %}
<label class="label cursor-pointer justify-start gap-3 rounded-md bg-base-200/50 px-3">
@ -531,6 +553,8 @@
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</fieldset>
<div class="card-actions pt-2 gap-3">

View file

@ -2064,17 +2064,20 @@ def agents_decommission_ack(agent_id, operation_id):
if agent.get("protocol_version") == 2 and payload.get("agent_session_id") != agent.get("agent_session_id"):
return _agent_json_response(409, "registration_required")
try:
previous_state = operation.get("state") if operation else None
operation, should_cleanup = agent_decommission.record_ack(agent_id, operation_id, payload)
if should_cleanup:
operation = agent_decommission.run_master_cleanup(operation_id)
elif operation.get("state") == "shutdown_scheduled":
operation = agent_decommission.complete_finalization(operation_id)
agent_decommission.notify_transition(operation, previous_state)
return _agent_json_response(
200,
"acknowledged",
operation=agent_decommission.serialize_operation(operation),
)
except agent_decommission.DecommissionError as error:
agent_decommission.notify_transition(agent_decommission.get_operation(operation_id), previous_state)
return _agent_json_response(error.http_status, error.code)
@api_v2_bp.route('/agents/<agent_id>/events', methods=['POST'])
@ -2236,6 +2239,15 @@ def agents_enroll(agent_id):
return jsonify({"status": "error", "message": "Agent decommission is in progress."}), 409
old_tunnel_id = agent.get("assigned_tunnel_id")
was_new_enrollment = agent.get("status") != "enrolled"
enrollment_context = {
"agent_name": agent.get("display_name") or f"agent-{agent_id[:8]}",
"agent_id": agent_id[:12],
"tunnel_name": tunnel_name,
"operation": "enroll",
"source": "admin",
"public_url": config.DOCKFLARE_PUBLIC_URL,
}
try:
from app.core.cloudflare_api import find_tunnel_via_api, create_tunnel_via_api
found_id, found_token = find_tunnel_via_api(tunnel_name)
@ -2250,26 +2262,35 @@ def agents_enroll(agent_id):
tunnel_ownership = "adopted"
if not tunnel_id:
notification_manager.emit("agent.enrollment_failed", agent_id, enrollment_context)
return jsonify({"status": "error", "message": "Failed to create/find tunnel."}), 500
cmd = {"action": "start_tunnel", "tunnel_name": tunnel_name, "tunnel_id": tunnel_id, "token": token}
existing_cmds = agent.get("commands", [])
existing_cmds = list(agent.get("commands", []))
existing_cmds.append(cmd)
update_agent(agent_id, {
persistence_failed = False
with state_lock:
current_agent = agents.get(agent_id)
if not current_agent:
persistence_failed = True
else:
previous_agent = copy.deepcopy(current_agent)
current_agent.update({
"assigned_tunnel_name": tunnel_name,
"assigned_tunnel_id": tunnel_id,
"assigned_tunnel_token": token,
"assigned_tunnel_ownership": tunnel_ownership,
"status": "enrolled",
"commands": existing_cmds,
"last_enrolled_at": datetime.utcnow().replace(tzinfo=timezone.utc).isoformat()
"last_enrolled_at": datetime.now(timezone.utc).isoformat(),
})
with state_lock:
rules_updated = False
previous_rules = {}
if not persistence_failed:
for rule in managed_rules.values():
if rule.get("source") == "agent" and rule.get("agent_id") == agent_id:
if rule.get("tunnel_name") != tunnel_name or rule.get("tunnel_id") != tunnel_id:
previous_rules[id(rule)] = copy.deepcopy(rule)
rule["tunnel_name"] = tunnel_name
rule["tunnel_id"] = tunnel_id
rule["tunnel_sync_pending"] = True
@ -2277,7 +2298,19 @@ def agents_enroll(agent_id):
rules_updated = True
if rules_updated:
logging.info(f"Updated {len([r for r in managed_rules.values() if r.get('agent_id') == agent_id])} rules for agent {agent_id} with tunnel name '{tunnel_name}'.")
save_state()
if not persistence_failed and not save_state():
current_agent.clear()
current_agent.update(previous_agent)
for rule in managed_rules.values():
previous_rule = previous_rules.get(id(rule))
if previous_rule is not None:
rule.clear()
rule.update(previous_rule)
persistence_failed = True
if persistence_failed:
notification_manager.emit("agent.enrollment_failed", agent_id, enrollment_context)
return jsonify({"status": "error", "message": "Failed to persist agent enrollment."}), 503
old_updated = not old_tunnel_id or old_tunnel_id == tunnel_id or update_cloudflare_config(old_tunnel_id)
new_updated = update_cloudflare_config(tunnel_id)
@ -2296,15 +2329,28 @@ def agents_enroll(agent_id):
if not hostname.startswith("*."):
create_cloudflare_dns_record(zone_id, hostname, tunnel_id)
if was_new_enrollment:
notification_manager.emit(
"agent.enrolled",
agent_id,
{**enrollment_context, "tunnel_id": str(tunnel_id)[:12]},
)
return jsonify({"status": "success", "message": "Agent enrolled and command queued.", "command": cmd}), 200
except Exception as e:
logging.error(f"Error enrolling agent {agent_id}: {e}", exc_info=True)
notification_manager.emit("agent.enrollment_failed", agent_id, enrollment_context)
return jsonify({"status": "error", "message": f"Exception during enrollment: {e}"}), 500
def _start_agent_decommission_response(agent_id):
try:
operation, _created = agent_decommission.start_decommission(agent_id)
operation, created = agent_decommission.start_decommission(agent_id)
if created:
notification_manager.emit(
"agent.decommission_started",
operation["operation_id"],
agent_decommission.notification_context(operation),
)
public_operation = agent_decommission.serialize_operation(operation)
return jsonify({
"status": "accepted",
@ -2342,19 +2388,27 @@ def agents_decommission_status(operation_id):
@api_v2_bp.route('/agent-decommissions/<operation_id>/retry', methods=['POST'])
def agents_decommission_retry(operation_id):
previous = agent_decommission.get_operation(operation_id)
previous_state = previous.get("state") if previous else None
try:
operation = agent_decommission.retry_operation(operation_id)
agent_decommission.notify_transition(operation, previous_state)
return jsonify({"status": "accepted", "operation": agent_decommission.serialize_operation(operation)}), 202
except agent_decommission.DecommissionError as error:
agent_decommission.notify_transition(agent_decommission.get_operation(operation_id), previous_state)
return jsonify({"status": "error", "code": error.code, "message": error.code}), error.http_status
@api_v2_bp.route('/agent-decommissions/<operation_id>/force', methods=['POST'])
def agents_decommission_force(operation_id):
previous = agent_decommission.get_operation(operation_id)
previous_state = previous.get("state") if previous else None
try:
operation = agent_decommission.force_cleanup(operation_id)
agent_decommission.notify_transition(operation, previous_state)
return jsonify({"status": "success", "operation": agent_decommission.serialize_operation(operation)}), 200
except agent_decommission.DecommissionError as error:
agent_decommission.notify_transition(agent_decommission.get_operation(operation_id), previous_state)
return jsonify({"status": "error", "code": error.code, "message": error.code}), error.http_status
@api_v2_bp.route('/agents/<agent_id>/remove', methods=['POST'])

View file

@ -130,6 +130,15 @@ class NotificationSettingsForm(FlaskForm):
docker_listener_failure = BooleanField('Docker listener failure')
agent_offline = BooleanField('Agent offline')
agent_online = BooleanField('Agent recovered')
agent_enrolled = BooleanField('Agent enrolled')
agent_enrollment_failed = BooleanField('Agent enrollment failed')
agent_decommission_started = BooleanField('Agent decommission started')
agent_decommission_completed = BooleanField('Agent decommission completed')
agent_decommission_failed = BooleanField('Agent decommission failed')
agent_decommission_stalled = BooleanField('Agent decommission stalled')
tunnel_down = BooleanField('Tunnel down')
tunnel_recovered = BooleanField('Tunnel recovered')
access_policy_created = BooleanField('Access Policy created')
access_policy_updated = BooleanField('Access Policy updated')
access_policy_deleted = BooleanField('Access Policy deleted')
submit_notifications = SubmitField('Save notification settings')

View file

@ -2162,7 +2162,10 @@ def create_access_group():
"policies": policies
}
access_groups[group_id] = new_group
save_state()
if not save_state():
access_groups.pop(group_id, None)
flash(_t('flash.access_group.create_error', error='persistence_failed'), "error")
return redirect(url_for('web.access_policies_page'))
from app import config
if config.USE_REUSABLE_POLICIES and len(effective_access_policies(new_group)) == 1:
@ -2176,6 +2179,19 @@ def create_access_group():
publish_state_event('snapshot_refresh')
notification_manager.emit(
"access.policy_created",
group_id,
{
"policy_name": display_name,
"policy_id": group_id,
"policy_type": "Reusable" if config.USE_REUSABLE_POLICIES else "Inline",
"identity_provider_count": len(new_group.get("allowed_idps") or []),
"rules_count": len(effective_access_policies(new_group)),
"source": "admin",
"public_url": config.DOCKFLARE_PUBLIC_URL,
},
)
flash(_t('flash.access_group.created', displayName=display_name), "success")
return redirect(url_for('web.access_policies_page'))
@ -2216,6 +2232,7 @@ def edit_access_group(group_id):
flash(_t('flash.access_group.update_error', error=str(e)), "error")
return redirect(url_for('web.access_policies_page'))
previous_group = copy.deepcopy(access_groups[group_id])
updated_group = {
"id": group_id,
"display_name": display_name,
@ -2227,7 +2244,20 @@ def edit_access_group(group_id):
"policies": policies
}
access_groups[group_id] = updated_group
save_state()
if not save_state():
access_groups[group_id] = previous_group
flash(_t('flash.access_group.update_error', error='persistence_failed'), "error")
return redirect(url_for('web.access_policies_page'))
affected_service_count = sum(
1
for rule in managed_rules.values()
if group_id in (
rule.get('access_group_id')
if isinstance(rule.get('access_group_id'), list)
else [rule.get('access_group_id')]
)
)
from app import config
if config.USE_REUSABLE_POLICIES and len(effective_access_policies(updated_group)) == 1:
@ -2241,6 +2271,20 @@ def edit_access_group(group_id):
publish_state_event('snapshot_refresh')
notification_manager.emit(
"access.policy_updated",
group_id,
{
"policy_name": display_name,
"policy_id": group_id,
"policy_type": "Reusable" if config.USE_REUSABLE_POLICIES else "Inline",
"identity_provider_count": len(updated_group.get("allowed_idps") or []),
"rules_count": len(effective_access_policies(updated_group)),
"affected_service_count": affected_service_count,
"source": "admin",
"public_url": config.DOCKFLARE_PUBLIC_URL,
},
)
flash(_t('flash.access_group.updated', displayName=display_name), "success")
reconcile_state_threaded()
return redirect(url_for('web.access_policies_page'))
@ -2266,24 +2310,46 @@ def delete_access_group(group_id):
flash(_t('flash.access_group.delete_in_use', displayName=access_groups[group_id]['display_name']), "error")
return redirect(url_for('web.access_policies_page'))
display_name = access_groups[group_id]['display_name']
previous_group = copy.deepcopy(access_groups[group_id])
display_name = previous_group['display_name']
from app import config
deletion_persisted = True
if config.USE_REUSABLE_POLICIES:
from app.core import reusable_policies
try:
reusable_policies.delete_access_group_and_policy(group_id)
deletion_persisted = reusable_policies.delete_access_group_and_policy(group_id)
if deletion_persisted:
logging.info(f"Deleted access group '{group_id}' and associated Cloudflare policy")
except Exception as e:
logging.error(f"Error deleting Cloudflare policy for '{group_id}': {e}", exc_info=True)
del access_groups[group_id]
save_state()
deletion_persisted = save_state()
if not deletion_persisted:
access_groups[group_id] = previous_group
else:
del access_groups[group_id]
save_state()
deletion_persisted = save_state()
if not deletion_persisted:
access_groups[group_id] = previous_group
if not deletion_persisted:
flash(_t('flash.access_group.update_error', error='persistence_failed'), "error")
return redirect(url_for('web.access_policies_page'))
publish_state_event('snapshot_refresh')
notification_manager.emit(
"access.policy_deleted",
group_id,
{
"policy_name": display_name,
"policy_id": group_id,
"policy_type": "Reusable" if config.USE_REUSABLE_POLICIES else "Inline",
"source": "admin",
"public_url": config.DOCKFLARE_PUBLIC_URL,
},
)
flash(_t('flash.access_group.deleted', displayName=display_name), "success")
return redirect(url_for('web.access_policies_page'))