Skip to content

Commit 91d87f7

Browse files
authored
Merge pull request #7188 from akatsoulas/visibility-filtering
Visibility filtering
2 parents 6c79a21 + d3c0ddf commit 91d87f7

File tree

20 files changed

+400
-62
lines changed

20 files changed

+400
-62
lines changed

.pre-commit-config.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ repos:
2323
2424
2525
files: "^svelte/"
26+
- repo: local
27+
hooks:
28+
- id: check-group-visibility-leak
29+
name: Check for group visibility leaks
30+
entry: >
31+
bash -c 'if grep -rn "\.groups\.all()" --include="*.py" --include="*.html" kitsune/ --exclude-dir=migrations | grep -v "# noqa: group-leak"; then
32+
echo "ERROR - Found .groups.all() which bypasses GroupProfile visibility";
33+
echo "Use profile.visible_group_profiles(viewer) or GroupProfile.objects.visible(viewer) instead";
34+
echo "See docs/conventions.md Security Patterns section";
35+
exit 1; fi'
36+
language: system
37+
pass_filenames: false
2638
- repo: https://github.com/pre-commit/mirrors-mypy
2739
rev: v1.15.0
2840
hooks:

CLAUDE.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,51 @@ Kitsune uses Django's i18n URL patterns with specific routing conventions:
212212
- API endpoints follow REST conventions in `api.py` files
213213
- Each app has dedicated `urls.py` and often `urls_api.py`
214214

215+
### Security Patterns
216+
217+
**GroupProfile Visibility - CRITICAL:**
218+
219+
GroupProfile has three visibility levels (PUBLIC, PRIVATE, MODERATED) that control who can see a group. **Never bypass these checks:**
220+
221+
**DANGER - Privacy Leak:**
222+
```python
223+
# WRONG - Exposes ALL groups, ignoring visibility settings
224+
user.groups.all() # Leaks PRIVATE groups!
225+
profile.user.groups.all() # Leaks PRIVATE groups!
226+
227+
# WRONG - Direct Group queryset bypasses GroupProfile visibility
228+
Group.objects.filter(user=some_user) # No visibility filtering!
229+
```
230+
231+
**SAFE - Respects visibility:**
232+
```python
233+
# Correct - Use Profile.visible_group_profiles()
234+
profile.visible_group_profiles(viewer=request.user)
235+
236+
# Correct - Use GroupProfile manager
237+
GroupProfile.objects.visible(viewer).filter(group__user=some_user)
238+
239+
# Correct - Check individual group visibility
240+
group_profile.can_view(request.user)
241+
```
242+
243+
**Why this matters:**
244+
- Django's `User.groups` relationship returns ALL Group objects, ignoring GroupProfile visibility
245+
- PRIVATE groups would be exposed to anyone viewing a user profile
246+
- Search indexing would leak private group membership
247+
- API responses would expose sensitive group information
248+
249+
**Safe patterns implemented:**
250+
- `Profile.visible_group_profiles(viewer)` - Get groups respecting visibility
251+
- `GroupProfile.objects.visible(viewer)` - Manager method for filtering
252+
- Search indexing excludes PRIVATE groups automatically
253+
- All views use visibility-aware methods
254+
255+
**Never:**
256+
- Use `user.groups.all()` in templates, views, or API serializers
257+
- Query `Group` model directly when GroupProfile visibility matters
258+
- Bypass `.visible()` filtering for user-facing data
259+
215260
### Template System
216261

217262
**Template Architecture:**
@@ -256,3 +301,25 @@ Kitsune uses Django's i18n URL patterns with specific routing conventions:
256301
- Dependabot automatically updates dependencies weekly
257302
- **Do not add trailing spaces at the end of files**
258303
- **Exception handling:** Be specific with exception types. Avoid catching plain `Exception` when the specific exception that could occur is known. For example, use `Model.DoesNotExist` when calling `.get()` on a Django queryset, or `KeyError` when accessing dictionary keys. This makes error handling more explicit and allows unexpected exceptions to be raised rather than silently caught.
304+
305+
### Naming Conventions
306+
307+
**Manager Methods:**
308+
- Custom manager methods should balance conciseness with explicitness
309+
- **Preferred:** Concise, verb-based names following Django's queryset API patterns: `all()`, `filter()`, `exclude()`, `visible()`, `active()`
310+
- **Alternative:** Verbose descriptive names when explicitness adds clarity (per "explicit is better than implicit")
311+
- Choose based on context: if the method name alone isn't clear, add descriptive prefixes
312+
- If a method returns a queryset, the name should read naturally when chained
313+
314+
**Example:**
315+
```python
316+
# Preferred - concise, follows Django patterns, clear in context
317+
GroupProfile.objects.visible(user)
318+
Article.objects.active()
319+
Question.objects.recent()
320+
321+
# Alternative - more explicit, acceptable when clarity is prioritized
322+
GroupProfile.objects.filter_by_visibility(user)
323+
Article.objects.get_active_articles()
324+
Question.objects.filter_by_recent()
325+
```

docs/conventions.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,37 @@ When creating and/or modifying Python functions/methods, we add [type
1919
hints](https://docs.python.org/3/library/typing.html) to their arguments
2020
and result, but only when it makes sense. See [our Architectural Decision Record](architecture/decisions/0004-type-checking.md) for more details.
2121

22+
## Security patterns
23+
24+
### GroupProfile visibility
25+
26+
**CRITICAL:** Never bypass GroupProfile visibility checks when displaying user's groups.
27+
28+
GroupProfile has three visibility levels:
29+
- **PUBLIC** - Visible to everyone
30+
- **PRIVATE** - Visible only to members and group moderators
31+
- **MODERATED** - Visible to members of specific groups
32+
33+
**WRONG - Privacy leak:**
34+
```python
35+
# This exposes ALL groups, including PRIVATE ones
36+
user.groups.all()
37+
profile.user.groups.all()
38+
```
39+
40+
**CORRECT - Respects visibility:**
41+
```python
42+
# Use the safe accessor method
43+
profile.visible_group_profiles(viewer=request.user)
44+
45+
# Or use the manager directly
46+
GroupProfile.objects.visible(viewer).filter(group__user=some_user)
47+
```
48+
49+
**Pre-commit protection:** A pre-commit hook checks for `.groups.all()` patterns. To bypass for legitimate internal use (like admin or Django internals), add `# noqa: group-leak` comment.
50+
51+
**Runtime monitoring:** In development, calls to `user.groups.all()` from views/templates will log security warnings.
52+
2253
# Git conventions
2354

2455
## Git workflow

kitsune/announcements/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class AnnouncementAdmin(admin.ModelAdmin):
2020
search_fields = ["creator__username"]
2121

2222
def get_groups(self, obj):
23-
groups = obj.groups.all()
23+
groups = obj.groups.all() # noqa: group-leak
2424
if groups:
2525
return ", ".join([g.name for g in groups])
2626
return ""

kitsune/announcements/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def send_group_email(announcement_id):
1717
except Announcement.DoesNotExist:
1818
return
1919

20-
groups = announcement.groups.all()
20+
groups = announcement.groups.all() # noqa: group-leak
2121
users = User.objects.filter(groups__in=groups).distinct()
2222
plain_content = bleach.clean(announcement.content_parsed, tags=[], strip=True).strip()
2323
email_kwargs = {

kitsune/groups/jinja2/groups/profile.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,23 @@ <h1>{{ profile.group.name }}</h1>
2626
</div>
2727

2828
<div id="group-leaders" class="editable">
29-
{% if user_can_manage_leaders %}
29+
{% if user_can_moderate %}
3030
<a class="edit" href="#group-leaders">{{ _('Edit group leaders') }}</a>
3131
{% endif %}
3232
<h2>{{ _('Group Leaders') }}</h2>
3333
<ul class="users leaders">
3434
{% for user in leaders %}
3535
<li>
3636
{{ user_row(user) }}
37-
{% if user_can_manage_leaders %}
37+
{% if user_can_moderate %}
3838
<div class="remove edit-mode">
3939
<a href="{{ url('groups.remove_leader', profile.slug, user.id) }}" title="{{ _('Remove user from leaders') }}">&#x2716;</a>
4040
</div>
4141
{% endif %}
4242
</li>
4343
{% endfor %}
4444
</ul>
45-
{% if user_can_manage_leaders %}
45+
{% if user_can_moderate %}
4646
<form id="add-member-form" class="edit-mode" action="{{ url('groups.add_leader', profile.slug) }}" method="POST">
4747
{% csrf_token %}
4848
{{ errorlist(leader_form) }}

kitsune/groups/managers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from django.contrib.auth.models import User
2+
from django.db.models import Exists, OuterRef, Q
3+
from django.db.models.functions import Length, Substr
4+
from treebeard.mp_tree import MP_NodeManager
5+
6+
7+
class GroupProfileManager(MP_NodeManager):
8+
def visible(self, user: User | None = None):
9+
"""Returns a queryset of all group profiles visible to the given user."""
10+
from kitsune.groups.models import GroupProfile
11+
12+
queryset = self.all()
13+
14+
if user and user.is_superuser:
15+
return queryset
16+
17+
public = Q(visibility=GroupProfile.Visibility.PUBLIC)
18+
19+
if not (user and user.is_authenticated):
20+
# Anonymous users only see public groups.
21+
return queryset.filter(public)
22+
23+
# Check if the user is a member of the group.
24+
member = Q(group__user=user)
25+
# Check if the user is a leader of the group or one of its ancestors.
26+
leader = Exists(
27+
GroupProfile.objects.filter(leaders=user).filter(
28+
path=Substr(OuterRef("path"), 1, Length("path"))
29+
)
30+
)
31+
private = Q(visibility=GroupProfile.Visibility.PRIVATE) & (member | leader)
32+
33+
moderated = Q(visibility=GroupProfile.Visibility.MODERATED) & Q(
34+
visible_to_groups__user=user
35+
)
36+
37+
return queryset.filter(public | private | moderated).distinct()

kitsune/groups/models.py

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from django.conf import settings
22
from django.contrib.auth.models import Group, User
33
from django.db import models
4-
from django.db.models import Q
4+
from django.db.models import Exists, OuterRef, Value
5+
from django.db.models.functions import Length, Substr
56
from django.template.defaultfilters import slugify
67
from django.utils.translation import gettext_lazy as _lazy
78
from treebeard.mp_tree import MP_Node
89

10+
from kitsune.groups.managers import GroupProfileManager
911
from kitsune.sumo.models import ModelBase
1012
from kitsune.sumo.urlresolvers import reverse
1113
from kitsune.wiki.parser import wiki_to_html
@@ -59,6 +61,8 @@ class Visibility(models.TextChoices):
5961
help_text="Groups that can see this group when visibility is MODERATED",
6062
)
6163

64+
objects = GroupProfileManager()
65+
6266
class Meta:
6367
ordering = ["slug"]
6468

@@ -77,46 +81,72 @@ def save(self, *args, **kwargs):
7781

7882
super().save(*args, **kwargs)
7983

80-
def can_edit(self, user):
81-
"""Check if user can edit this group.
84+
def can_moderate_group(self, user):
85+
"""
86+
Check if user can moderate this group (manage members and leaders).
8287
83-
If no leaders exist, delegates to parent leaders.
88+
Direct leaders have full control. If this group has no leaders,
89+
delegate to nearest ancestor with leaders.
8490
"""
85-
if user.is_superuser or self.leaders.filter(pk=user.pk).exists():
91+
if not (user and user.is_authenticated):
92+
return False
93+
94+
if user.is_superuser:
8695
return True
87-
if not self.leaders.exists():
88-
parent = self.get_parent()
89-
if parent:
90-
return parent.can_edit(user)
91-
return False
9296

93-
def can_manage_members(self, user):
94-
"""Check if user can add/remove members."""
95-
return self.can_edit(user)
97+
# Is the user a leader of this group, if it has leaders,
98+
# or its nearest ancestor group with leaders, if one exists.
99+
return bool(
100+
GroupProfile.objects.filter(
101+
leaders__isnull=False,
102+
path=Substr(Value(self.path), 1, Length("path")),
103+
)
104+
.order_by("-path")
105+
.annotate(
106+
is_leader=Exists(
107+
GroupProfile.objects.filter(
108+
pk=OuterRef("pk"),
109+
leaders=user,
110+
)
111+
)
112+
)
113+
.values_list("is_leader", flat=True)
114+
.first()
115+
)
96116

97-
def can_manage_leaders(self, user):
98-
"""Check if user can add/remove leaders."""
99-
return user.is_superuser or self.leaders.filter(pk=user.pk).exists()
117+
def can_edit(self, user):
118+
"""Check if user can edit this group profile."""
119+
return self.can_moderate_group(user)
100120

101121
def can_view(self, user):
102122
"""Check if user can view this group based on visibility setting."""
123+
# PUBLIC groups are visible to everyone
124+
if self.visibility == self.Visibility.PUBLIC:
125+
return True
126+
127+
# Non-authenticated users can only see PUBLIC groups
128+
if not (user and user.is_authenticated):
129+
return False
130+
131+
# Superusers can see everything
103132
if user.is_superuser:
104133
return True
105134

135+
# Check based on visibility level
106136
match self.visibility:
107-
case self.Visibility.PRIVATE:
108-
return self.group.user_set.filter(pk=user.pk).exists()
109-
case self.Visibility.PUBLIC:
110-
return True
111137
case self.Visibility.MODERATED:
112-
return self.visible_to_groups.filter(pk__in=user.groups.all()).exists()
138+
# Visible to members of specified groups
139+
return self.visible_to_groups.filter(user=user).exists()
140+
case self.Visibility.PRIVATE:
141+
# Members can see their own group
142+
if self.group.user_set.filter(pk=user.pk).exists():
143+
return True
144+
# Moderators (including delegated) can see private groups
145+
return self.can_moderate_group(user)
146+
case _:
147+
return False
113148

114149
def get_visible_children(self, user):
115150
"""Get child groups visible to this user."""
116-
if user.is_superuser:
117-
return self.get_children()
118-
119-
public = Q(visibility=self.Visibility.PUBLIC)
120-
private = Q(visibility=self.Visibility.PRIVATE) & Q(group__user_set__pk=user.pk)
121-
moderated = Q(visibility=self.Visibility.MODERATED) & Q(visible_to_groups__in=user.groups.all())
122-
return self.get_children().filter(public | private | moderated)
151+
children = self.get_children()
152+
return GroupProfile.objects.filter(pk__in=children).visible(user)

0 commit comments

Comments
 (0)