Skip to content
17 changes: 14 additions & 3 deletions django/thunderstore/account/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.db import transaction

from thunderstore.account.models import ServiceAccount
from thunderstore.core.exceptions import PermissionValidationError
from thunderstore.core.types import UserType
from thunderstore.repository.models import Team

Expand All @@ -18,7 +19,9 @@ def __init__(self, user: UserType, *args, **kwargs) -> None:

def clean_team(self) -> Team:
team = self.cleaned_data["team"]
team.ensure_can_create_service_account(self.user)
errors, _ = team.validate_can_create_service_account(self.user)
if errors:
raise forms.ValidationError(errors)
Comment on lines +22 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use PermissionValidationError to keep is_public context for create flow

For consistency with delete/edit forms and to retain is_public, raise PermissionValidationError instead of a plain forms.ValidationError.

-        errors, _ = team.validate_can_create_service_account(self.user)
-        if errors:
-            raise forms.ValidationError(errors)
+        errors, is_public = team.validate_can_create_service_account(self.user)
+        if errors:
+            raise PermissionValidationError(errors, is_public=is_public)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
errors, _ = team.validate_can_create_service_account(self.user)
if errors:
raise forms.ValidationError(errors)
errors, is_public = team.validate_can_create_service_account(self.user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)
🤖 Prompt for AI Agents
In django/thunderstore/account/forms.py around lines 22-24, replace raising
forms.ValidationError with raising PermissionValidationError so the create flow
preserves is_public context; update the code to raise
PermissionValidationError(errors, is_public=self.is_public) (or the equivalent
used elsewhere in delete/edit forms) and add the corresponding import for
PermissionValidationError from the module used by the other account forms.

return team

@transaction.atomic
Expand All @@ -42,7 +45,11 @@ def __init__(self, user: UserType, *args, **kwargs) -> None:

def clean_service_account(self) -> ServiceAccount:
service_account = self.cleaned_data["service_account"]
service_account.owner.ensure_can_delete_service_account(self.user)
errors, is_public = service_account.owner.validate_can_delete_service_account(
self.user
)
if errors:
raise PermissionValidationError(errors, is_public=is_public)
return service_account

def save(self) -> None:
Expand All @@ -61,7 +68,11 @@ def __init__(self, user: UserType, *args, **kwargs) -> None:

def clean_service_account(self) -> ServiceAccount:
service_account = self.cleaned_data["service_account"]
service_account.owner.ensure_can_edit_service_account(self.user)
errors, is_public = service_account.owner.validate_can_edit_service_account(
self.user
)
if errors:
raise PermissionValidationError(errors, is_public=is_public)
return service_account

def save(self) -> ServiceAccount:
Expand Down
20 changes: 16 additions & 4 deletions django/thunderstore/api/cyberstorm/services/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
def disband_team(user: UserType, team_name: str) -> None:
teams = Team.objects.exclude(is_active=False)
team = get_object_or_404(teams, name=team_name)
team.ensure_user_can_access(user)
team.ensure_user_can_disband(user)
validators = [team.validate_user_can_access, team.validate_user_can_disband]

for validator in validators:
errors, is_public = validator(user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

Comment on lines +15 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Type annotation mismatch in Team.validate_user_can_disband may break type-checking

This caller expects validators to return Tuple[List[str], bool]. In repository/models/team.py, validate_user_can_disband is annotated as Tuple[str, bool] in the provided snippet while returning lists. Align annotation to Tuple[List[str], bool].

Proposed fix in django/thunderstore/repository/models/team.py:

def validate_user_can_disband(self, user: Optional[UserType]) -> Tuple[List[str], bool]:
    # ...

Run to verify annotations across all team validators:


🏁 Script executed:

#!/bin/bash
# Ensure all validate_* methods on Team return Tuple[List[str], bool]
rg -nP --type=py '(def\s+validate_[a-zA-Z_]+\s*\(.*\)\s*->\s*Tuple\[[^\]]+\])' -C1 | sed -n '1,200p'

Length of output: 397


Action: Correct the return‐type annotation of validate_user_can_disband

The current signature in django/thunderstore/repository/models/team.py (line 382) is:

def validate_user_can_disband(self, user: Optional[UserType]) -> Tuple[str, bool]:
    …

but callers expect Tuple[List[str], bool]. Update it to:

-    def validate_user_can_disband(self, user: Optional[UserType]) -> Tuple[str, bool]:
+    def validate_user_can_disband(self, user: Optional[UserType]) -> Tuple[List[str], bool]:
         # ...

Also ensure you have:

from typing import Tuple, List, Optional

so that List is recognized.

After this change, rerun your type‐checker to confirm no further mismatches.

🤖 Prompt for AI Agents
In django/thunderstore/repository/models/team.py around line 382 the function
validate_user_can_disband currently declares its return type as Tuple[str, bool]
but callers expect Tuple[List[str], bool]; update the signature to return
Tuple[List[str], bool] and adjust any internal return sites to return a list of
error strings (even if single element) instead of a bare string; also ensure the
module imports Tuple, List, Optional from typing (add "from typing import Tuple,
List, Optional" if missing) and then rerun the type-checker to verify there are
no remaining mismatches.

💡 Verification agent

🧩 Analysis chain

Bug risk: create_team uses getattr(user, "service_account", None) which can raise DoesNotExist

Reverse OneToOne access can raise ServiceAccount.DoesNotExist, even when a default is provided to getattr. Use an existence query or a guarded try/except.

Outside this hunk, update the check in create_team(...):

from thunderstore.account.models import ServiceAccount
# ...
if ServiceAccount.objects.filter(user=user).exists():
    raise PermissionValidationError("Service accounts cannot create teams")

Alternatively, guarded access:

from django.core.exceptions import ObjectDoesNotExist
try:
    user.service_account
    is_sa = True
except (AttributeError, ObjectDoesNotExist):
    is_sa = False
if is_sa:
    raise PermissionValidationError("Service accounts cannot create teams")

To find other unsafe probes of service_account:


🏁 Script executed:

#!/bin/bash
rg -nP --type=py -C2 '(hasattr\([^,]+,\s*[\'"]service_account[\'"]\)|getattr\([^,]+,\s*[\'"]service_account[\'"])'

Length of output: 297


🏁 Script executed:

#!/bin/bash
# Search for the definition and implementation of create_team
rg -nP --type=py "def create_team" -C5

echo
echo "Searching for unsafe getattr on service_account"
rg -nP --type=py -C2 "getattr\([^,]+,\s*['\"]service_account['\"]" || true

echo
echo "Searching for hasattr on service_account"
rg -nP --type=py -C2 "hasattr\([^,]+,\s*['\"]service_account['\"]" || true

echo
echo "Searching for direct attribute access .service_account"
rg -nP --type=py -C2 "\.service_account\b" || true

Length of output: 23347


Prevent ServiceAccount.DoesNotExist by avoiding getattr(user, "service_account")

The current checks use getattr(user, "service_account", None), but Django’s reverse OneToOne lookup raises ServiceAccount.DoesNotExist (a subclass of ObjectDoesNotExist) when no related instance exists—this isn’t caught by getattr’s default, so it will error instead of returning None. We need to update both occurrences to safely detect service accounts.

Affected locations:

  • django/thunderstore/api/cyberstorm/services/team.py (lines 29–30)
  • django/thunderstore/repository/forms/team.py (lines 46–47)

Replace this pattern:

# current unsafe check
if getattr(user, "service_account", None) is not None:
    raise PermissionValidationError("Service accounts cannot create teams")

With either an existence query:

from thunderstore.account.models import ServiceAccount

if ServiceAccount.objects.filter(user=user).exists():
    raise PermissionValidationError("Service accounts cannot create teams")

Or a guarded attribute lookup:

from django.core.exceptions import ObjectDoesNotExist

try:
    # will raise ServiceAccount.DoesNotExist if none exists
    user.service_account
    is_sa = True
except (AttributeError, ObjectDoesNotExist):
    is_sa = False

if is_sa:
    raise PermissionValidationError("Service accounts cannot create teams")

Please update both files accordingly to eliminate the crash risk.

🤖 Prompt for AI Agents
In django/thunderstore/api/cyberstorm/services/team.py around lines 15 to 21
(and also update django/thunderstore/api/cyberstorm/services/team.py lines 29–30
as referenced) replace the unsafe getattr(user, "service_account", None) check
which can raise ServiceAccount.DoesNotExist with a safe existence check: either
use ServiceAccount.objects.filter(user=user).exists() to detect service
accounts, or perform a guarded lookup that catches ObjectDoesNotExist (and
AttributeError) around user.service_account; then raise
PermissionValidationError when a service account exists. Apply the same change
to django/thunderstore/repository/forms/team.py around lines 46–47 so both
locations use the same safe pattern.

team.delete()


Expand All @@ -35,8 +40,15 @@ def create_team(user: UserType, team_name: str) -> Team:

@transaction.atomic
def update_team(agent: UserType, team: Team, donation_link: str) -> Team:
team.ensure_user_can_access(agent)
team.ensure_user_can_edit_info(agent)
validators = [
team.validate_user_can_access,
team.validate_user_can_edit_info,
]

for validator in validators:
errors, is_public = validator(agent)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

team.donation_link = donation_link
team.save()
Expand Down
19 changes: 18 additions & 1 deletion django/thunderstore/permissions/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Optional
from typing import List, Optional, Tuple
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Import ObjectDoesNotExist to safely probe reverse OneToOne relations

Accessing user.service_account can raise ServiceAccount.DoesNotExist, which is a subclass of ObjectDoesNotExist. We need this import for a safe try/except around the service account check below.

 from typing import List, Optional, Tuple
+from django.core.exceptions import ObjectDoesNotExist
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple
from django.core.exceptions import ObjectDoesNotExist
🤖 Prompt for AI Agents
In django/thunderstore/permissions/utils.py around line 1, the module currently
imports typing only and does not import ObjectDoesNotExist; accessing
user.service_account can raise ServiceAccount.DoesNotExist (a subclass of
ObjectDoesNotExist), so add "from django.core.exceptions import
ObjectDoesNotExist" to the imports and update the service account check to wrap
the access in a try/except that catches ObjectDoesNotExist (instead of or in
addition to ServiceAccount.DoesNotExist) to safely handle missing reverse
OneToOne relations and avoid unhandled exceptions.


from thunderstore.core.exceptions import PermissionValidationError
from thunderstore.core.types import UserType
Expand All @@ -16,3 +16,20 @@ def validate_user(
"Service accounts are unable to perform this action"
)
return user


def check_user_permissions(
user: Optional[UserType], allow_serviceaccount: bool = False
) -> Tuple[List[str], bool]:
errors = []
public_error = True # Set to False if you want to hide certain errors

if not user or not user.is_authenticated:
errors.append("Must be authenticated")
elif not user.is_active:
errors.append("User has been deactivated")
public_error = False
elif hasattr(user, "service_account") and not allow_serviceaccount:
errors.append("Service accounts are unable to perform this action")

return errors, public_error
Comment on lines +21 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: hasattr/getattr on reverse OneToOne may raise DoesNotExist (crashes permission checks)

hasattr(user, "service_account") on a User without a related ServiceAccount raises ServiceAccount.DoesNotExist (not AttributeError), causing this function to error instead of returning a validation error. Replace with a safe try/except check.

 def check_user_permissions(
     user: Optional[UserType], allow_serviceaccount: bool = False
 ) -> Tuple[List[str], bool]:
     errors = []
     public_error = True  # Set to False if you want to hide certain errors
 
     if not user or not user.is_authenticated:
         errors.append("Must be authenticated")
     elif not user.is_active:
         errors.append("User has been deactivated")
         public_error = False
-    elif hasattr(user, "service_account") and not allow_serviceaccount:
-        errors.append("Service accounts are unable to perform this action")
+    else:
+        # Reverse OneToOne access may raise DoesNotExist when the relation is missing
+        try:
+            getattr(user, "service_account")
+            is_service_account = True
+        except (AttributeError, ObjectDoesNotExist):
+            is_service_account = False
+        if is_service_account and not allow_serviceaccount:
+            errors.append("Service accounts are unable to perform this action")
 
     return errors, public_error

As a follow-up, mirror the same safe pattern in validate_user(...) to prevent identical crashes there:

# In validate_user(...)
try:
    getattr(user, "service_account")
    is_service_account = True
except (AttributeError, ObjectDoesNotExist):
    is_service_account = False
if is_service_account and not allow_serviceaccount:
    raise PermissionValidationError("Service accounts are unable to perform this action")

I can send a separate PR patch touching both places if helpful.

🤖 Prompt for AI Agents
In django/thunderstore/permissions/utils.py around lines 21 to 35, using
hasattr(user, "service_account") can raise ServiceAccount.DoesNotExist (an
ObjectDoesNotExist subclass) and crash permission checks; replace the hasattr
call with a safe getattr/try-except that catches AttributeError and
django.core.exceptions.ObjectDoesNotExist, set a boolean like is_service_account
accordingly, and append the service account error only when is_service_account
is True and allow_serviceaccount is False; also apply the same safe
getattr/try-except pattern in validate_user(...) to avoid the identical crash
there.

51 changes: 35 additions & 16 deletions django/thunderstore/repository/forms/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,13 @@ def __init__(self, user: Optional[UserType], *args, **kwargs):
def clean(self):
result = super().clean()
team = self.cleaned_data.get("team")
if team:
team.ensure_user_can_manage_members(self.user)
else:
raise ValidationError("Invalid team")
if not team:
raise ValidationError("Team is required")

errors, is_public = team.validate_can_manage_members(self.user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

return result


Expand All @@ -97,8 +100,14 @@ def __init__(self, user: Optional[UserType], *args, **kwargs):
def clean_membership(self):
membership = self.cleaned_data["membership"]
if membership.user != self.user:
membership.team.ensure_user_can_manage_members(self.user)
membership.team.ensure_member_can_be_removed(membership)
errors, is_public = membership.team.validate_can_manage_members(self.user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

error, is_public = membership.team.validate_member_can_be_removed(membership)
if error:
raise PermissionValidationError(error, is_public=is_public)

return membership

def save(self):
Expand All @@ -120,23 +129,29 @@ def clean_role(self):
team = self.instance.team
except ObjectDoesNotExist:
team = None
if team:
team.ensure_member_role_can_be_changed(
member=self.instance, new_role=new_role
)
else:

if not team:
raise ValidationError("Team is missing")

error, is_public = team.validate_member_role_be_changed(self.instance, new_role)
if error:
raise PermissionValidationError(error, is_public=is_public)

Comment on lines +133 to +139
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix method name: call validate_member_role_can_be_changed.

Current call uses a non-existent name (validate_member_role_be_changed), which will raise AttributeError at runtime.

-        error, is_public = team.validate_member_role_be_changed(self.instance, new_role)
+        error, is_public = team.validate_member_role_can_be_changed(self.instance, new_role)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not team:
raise ValidationError("Team is missing")
error, is_public = team.validate_member_role_be_changed(self.instance, new_role)
if error:
raise PermissionValidationError(error, is_public=is_public)
if not team:
raise ValidationError("Team is missing")
error, is_public = team.validate_member_role_can_be_changed(self.instance, new_role)
if error:
raise PermissionValidationError(error, is_public=is_public)
🤖 Prompt for AI Agents
django/thunderstore/repository/forms/team.py around lines 133 to 139: the code
calls a non-existent method validate_member_role_be_changed which causes
AttributeError at runtime; change the call to
validate_member_role_can_be_changed (preserving the same args and handling of
returned error and is_public), and run tests/lint to ensure no other references
to the old name remain.

return new_role

def clean(self):
try:
team = self.instance.team
except ObjectDoesNotExist:
team = None
if team:
team.ensure_user_can_manage_members(self.user)
else:

if not team:
raise ValidationError("Team is missing")

errors, is_public = team.validate_can_manage_members(self.user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

return super().clean()


Expand All @@ -161,12 +176,16 @@ def clean_verification(self):
def clean(self):
if not self.instance.pk:
raise ValidationError("Missing team instance")
self.instance.ensure_user_can_disband(self.user)
error, is_public = self.instance.validate_user_can_access(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
return super().clean()

@transaction.atomic
def save(self, **kwargs):
self.instance.ensure_user_can_disband(self.user)
error, is_public = self.instance.validate_user_can_access(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
self.instance.delete()
Comment on lines +179 to 189
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Security: use validate_user_can_disband, not validate_user_can_access.

Disband requires ownership (and no packages). Using “access” would allow any member to disband a team. Swap to the stricter validator.

-        error, is_public = self.instance.validate_user_can_access(self.user)
+        error, is_public = self.instance.validate_user_can_disband(self.user)
         if error:
             raise PermissionValidationError(error, is_public=is_public)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
error, is_public = self.instance.validate_user_can_access(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
return super().clean()
@transaction.atomic
def save(self, **kwargs):
self.instance.ensure_user_can_disband(self.user)
error, is_public = self.instance.validate_user_can_access(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
self.instance.delete()
@transaction.atomic
def save(self, **kwargs):
error, is_public = self.instance.validate_user_can_disband(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
self.instance.delete()
🤖 Prompt for AI Agents
In django/thunderstore/repository/forms/team.py around lines 179 to 189, the
code calls validate_user_can_access for both cleaning and saving (deletion),
which is too permissive for disbanding a team; replace those calls with
validate_user_can_disband so the stricter ownership-and-no-packages check is
enforced, and keep raising PermissionValidationError(error, is_public=is_public)
when the disband validator returns an error.

Comment on lines +186 to 189
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Apply the same disband validator in save().

Mirror the clean() logic to enforce owner/no-packages at execution time.

-        error, is_public = self.instance.validate_user_can_access(self.user)
+        error, is_public = self.instance.validate_user_can_disband(self.user)
         if error:
             raise PermissionValidationError(error, is_public=is_public)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
error, is_public = self.instance.validate_user_can_access(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
self.instance.delete()
error, is_public = self.instance.validate_user_can_disband(self.user)
if error:
raise PermissionValidationError(error, is_public=is_public)
self.instance.delete()
🤖 Prompt for AI Agents
In django/thunderstore/repository/forms/team.py around lines 186 to 189, the
form's save() currently deletes the team without re-checking the same disband
validator used in clean(); update save() to call
self.instance.validate_user_can_access(self.user) before performing the delete,
raise PermissionValidationError(error, is_public=is_public) if it returns an
error, and only then proceed to self.instance.delete() so the owner/no-packages
check is enforced at execution time.



Expand Down
9 changes: 7 additions & 2 deletions django/thunderstore/repository/models/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from thunderstore.cache.enums import CacheBustCondition
from thunderstore.cache.tasks import invalidate_cache_on_commit_async
from thunderstore.core.enums import OptionalBoolChoice
from thunderstore.core.exceptions import PermissionValidationError
from thunderstore.core.mixins import AdminLinkMixin
from thunderstore.core.types import UserType
from thunderstore.core.utils import check_validity
Expand Down Expand Up @@ -335,13 +336,17 @@ def ensure_user_can_manage_deprecation(self, user: Optional[UserType]) -> None:
):
return

self.owner.ensure_user_can_manage_packages(user)
errors, is_public = self.owner.validate_user_can_manage_packages(user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

def can_user_manage_deprecation(self, user: Optional[UserType]) -> bool:
return check_validity(lambda: self.ensure_user_can_manage_deprecation(user))

def ensure_user_can_manage_wiki(self, user: Optional[UserType]) -> None:
return self.owner.ensure_user_can_manage_packages(user)
errors, is_public = self.owner.validate_user_can_manage_packages(user)
if errors:
raise PermissionValidationError(errors, is_public=is_public)

def can_user_manage_wiki(self, user: Optional[UserType]) -> bool:
return self.owner.can_user_manage_packages(user)
Expand Down
Loading
Loading