Skip to content

Commit ca5bdfd

Browse files
dpgasparclaudevillebro
authored
feat: Add security model signals for User, Role, and Group CRUD operations (#2432)
* feat: Add security model signals for User, Role, and Group CRUD operations * fix tests * fix tests * fix tests * refactor: Use self.current_user instead of custom _get_current_user method Simplify signal triggered_by by using the existing current_user property from BaseSecurityManager instead of a redundant helper method. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Handle missing app/request context in signal emission - Add app context check in _signals_enabled() to prevent errors during app initialization - Add _get_triggered_by_user() helper to safely get current user, returning None when outside request context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: Add coverage tests for security signals Add tests for: - SecurityModelChangeEvent properties (is_pre_commit, is_create, is_update, is_delete) - Post-commit error handling (errors logged but not raised) - _signals_enabled behavior outside app context - _get_triggered_by_user returning None without request context 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Use mock for app context test to avoid CI issues Mock has_app_context instead of relying on actual context teardown which behaves differently in CI environment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Update tests/test_security_signals.py Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com> * Update tests/test_security_signals.py Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com> * fix: Correct assertIsEqual to assertEqual in test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Ville Brofeldt <33317356+villebro@users.noreply.github.com>
1 parent b69c8f1 commit ca5bdfd

File tree

5 files changed

+1402
-20
lines changed

5 files changed

+1402
-20
lines changed

flask_appbuilder/const.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,24 @@
8585
""" User added, format with username """
8686
LOGMSG_INF_SEC_UPD_USER = "Updated user %s"
8787
""" User updated, format with username """
88+
LOGMSG_INF_SEC_DEL_USER = "Deleted user %s"
89+
""" User deleted, format with user id """
90+
LOGMSG_ERR_SEC_DEL_USER = "Error deleting user: %s"
91+
""" Error deleting user, format with err message """
8892
LOGMSG_INF_SEC_UPD_ROLE = "Updated role %s"
8993
""" Role updated, format with role name """
94+
LOGMSG_INF_SEC_DEL_ROLE = "Deleted role %s"
95+
""" Role deleted, format with role id """
96+
LOGMSG_ERR_SEC_DEL_ROLE = "Error deleting role: %s"
97+
""" Error deleting role, format with err message """
98+
LOGMSG_INF_SEC_ADD_GROUP = "Added group %s"
99+
""" Group added, format with group name """
100+
LOGMSG_INF_SEC_UPD_GROUP = "Updated group %s"
101+
""" Group updated, format with group name """
102+
LOGMSG_INF_SEC_DEL_GROUP = "Deleted group %s"
103+
""" Group deleted, format with group id """
104+
LOGMSG_ERR_SEC_DEL_GROUP = "Error deleting group: %s"
105+
""" Error deleting group, format with err message """
90106
LOGMSG_ERR_SEC_UPD_ROLE = "An error occurred updating role %s"
91107
""" Role updated Error, format with role name """
92108

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Event classes for Flask-AppBuilder security signals.
3+
4+
This module defines the event payload structures sent with security signals.
5+
"""
6+
7+
from dataclasses import dataclass, field
8+
from datetime import datetime
9+
from typing import Any, Dict, Literal, Optional
10+
11+
12+
@dataclass
13+
class SecurityModelChangeEvent:
14+
"""
15+
Payload sent with security model change signals.
16+
17+
This event is passed to signal handlers when User, Role, or Group
18+
models are created, updated, or deleted.
19+
20+
Attributes:
21+
model_type: The type of model ("user", "role", or "group")
22+
action: The action being performed. Pre-commit actions use present
23+
participle ("creating", "updating", "deleting"). Post-commit
24+
actions use past tense ("created", "updated", "deleted").
25+
model_id: The primary key of the model. For creates, this is
26+
available after flush() but before commit().
27+
model: The model instance. For deletes, this may be None or
28+
an expired instance after commit.
29+
timestamp: When the event was created.
30+
changes: For updates, a dict of changed fields with old/new values.
31+
Example: {"email": {"old": "a@b.com", "new": "x@y.com"}}
32+
triggered_by: The User who triggered this change, if available.
33+
is_committed: False for pre-commit signals, True for post-commit.
34+
35+
Example::
36+
37+
@user_creating.connect
38+
def handle_user_creating(sender, event):
39+
print(f"Creating user {event.model.username}")
40+
print(f"User ID (from flush): {event.model_id}")
41+
print(f"Is committed: {event.is_committed}") # False
42+
43+
@user_updated.connect
44+
def handle_user_updated(sender, event):
45+
print(f"Updated user {event.model_id}")
46+
print(f"Changes: {event.changes}")
47+
print(f"Changed by: {event.triggered_by}")
48+
"""
49+
50+
model_type: Literal["user", "role", "group"]
51+
action: Literal[
52+
"creating",
53+
"updating",
54+
"deleting", # Pre-commit
55+
"created",
56+
"updated",
57+
"deleted", # Post-commit
58+
]
59+
model_id: Any
60+
model: Optional[Any] = None
61+
timestamp: datetime = field(default_factory=datetime.utcnow)
62+
changes: Optional[Dict[str, Dict[str, Any]]] = None
63+
triggered_by: Optional[Any] = None
64+
is_committed: bool = False
65+
66+
@property
67+
def is_pre_commit(self) -> bool:
68+
"""True if this is a pre-commit event (transaction still open)."""
69+
return not self.is_committed
70+
71+
@property
72+
def is_create(self) -> bool:
73+
"""True if this is a create operation."""
74+
return self.action in ("creating", "created")
75+
76+
@property
77+
def is_update(self) -> bool:
78+
"""True if this is an update operation."""
79+
return self.action in ("updating", "updated")
80+
81+
@property
82+
def is_delete(self) -> bool:
83+
"""True if this is a delete operation."""
84+
return self.action in ("deleting", "deleted")
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
Signals for Flask-AppBuilder security model changes.
3+
4+
This module provides Blinker signals that fire when User, Role, and Group
5+
models are created, updated, or deleted. There are two types of signals:
6+
7+
**Pre-commit signals** (e.g., `user_creating`):
8+
Fire BEFORE the transaction is committed. Handlers can modify the
9+
database session and their changes will be committed atomically
10+
with the original operation.
11+
12+
**Post-commit signals** (e.g., `user_created`):
13+
Fire AFTER the transaction is committed. Use these for notifications,
14+
logging, or other side effects that don't need transactional guarantees.
15+
16+
Example usage::
17+
18+
from flask_appbuilder.security.signals import user_creating, user_created
19+
20+
# Pre-commit handler - runs in same transaction
21+
@user_creating.connect
22+
def on_user_creating(sender, event):
23+
# Add related record in same transaction
24+
my_record = MyModel(user_id=event.model.id)
25+
db.session.add(my_record) # Will commit with User
26+
27+
# Post-commit handler - runs after transaction
28+
@user_created.connect
29+
def on_user_created(sender, event):
30+
# Send notification (transaction already committed)
31+
send_welcome_email(event.model.email)
32+
"""
33+
34+
from blinker import Namespace
35+
36+
# Create a namespace for FAB security signals
37+
_signals = Namespace()
38+
39+
# =============================================================================
40+
# PRE-COMMIT SIGNALS
41+
# These fire BEFORE commit - handlers can modify the session
42+
# =============================================================================
43+
44+
# User signals (pre-commit)
45+
user_creating = _signals.signal("user-creating")
46+
"""Signal sent before a user is created and committed.
47+
48+
:param sender: The SecurityManager instance
49+
:param event: SecurityModelChangeEvent with model_type="user"
50+
"""
51+
52+
user_updating = _signals.signal("user-updating")
53+
"""Signal sent before a user update is committed.
54+
55+
:param sender: The SecurityManager instance
56+
:param event: SecurityModelChangeEvent with model_type="user"
57+
"""
58+
59+
user_deleting = _signals.signal("user-deleting")
60+
"""Signal sent before a user is deleted and committed.
61+
62+
:param sender: The SecurityManager instance
63+
:param event: SecurityModelChangeEvent with model_type="user"
64+
"""
65+
66+
# Role signals (pre-commit)
67+
role_creating = _signals.signal("role-creating")
68+
"""Signal sent before a role is created and committed."""
69+
70+
role_updating = _signals.signal("role-updating")
71+
"""Signal sent before a role update is committed."""
72+
73+
role_deleting = _signals.signal("role-deleting")
74+
"""Signal sent before a role is deleted and committed."""
75+
76+
# Group signals (pre-commit)
77+
group_creating = _signals.signal("group-creating")
78+
"""Signal sent before a group is created and committed."""
79+
80+
group_updating = _signals.signal("group-updating")
81+
"""Signal sent before a group update is committed."""
82+
83+
group_deleting = _signals.signal("group-deleting")
84+
"""Signal sent before a group is deleted and committed."""
85+
86+
# =============================================================================
87+
# POST-COMMIT SIGNALS
88+
# These fire AFTER commit - for notifications and side effects only
89+
# =============================================================================
90+
91+
# User signals (post-commit)
92+
user_created = _signals.signal("user-created")
93+
"""Signal sent after a user has been created and committed.
94+
95+
:param sender: The SecurityManager instance
96+
:param event: SecurityModelChangeEvent with model_type="user"
97+
"""
98+
99+
user_updated = _signals.signal("user-updated")
100+
"""Signal sent after a user update has been committed."""
101+
102+
user_deleted = _signals.signal("user-deleted")
103+
"""Signal sent after a user has been deleted and committed."""
104+
105+
# Role signals (post-commit)
106+
role_created = _signals.signal("role-created")
107+
"""Signal sent after a role has been created and committed."""
108+
109+
role_updated = _signals.signal("role-updated")
110+
"""Signal sent after a role update has been committed."""
111+
112+
role_deleted = _signals.signal("role-deleted")
113+
"""Signal sent after a role has been deleted and committed."""
114+
115+
# Group signals (post-commit)
116+
group_created = _signals.signal("group-created")
117+
"""Signal sent after a group has been created and committed."""
118+
119+
group_updated = _signals.signal("group-updated")
120+
"""Signal sent after a group update has been committed."""
121+
122+
group_deleted = _signals.signal("group-deleted")
123+
"""Signal sent after a group has been deleted and committed."""

0 commit comments

Comments
 (0)