Skip to content

Commit 8a1a37c

Browse files
authored
Merge branch 'dev' into docs/fix-backticks-and-image-path
2 parents 9a054a8 + 4c5de62 commit 8a1a37c

18 files changed

Lines changed: 627 additions & 133 deletions

docs/package-lock.json

Lines changed: 112 additions & 112 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
},
2828
"devDependencies": {
2929
"prettier": "3.7.4",
30-
"vite": "7.2.7"
30+
"vite": "7.3.0"
3131
},
3232
"engines": {
3333
"node": ">=20.11.0"

dojo/api_v2/permissions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
user_has_configuration_permission,
1313
user_has_global_permission,
1414
user_has_permission,
15+
user_is_superuser_or_global_owner,
1516
)
1617
from dojo.authorization.roles_permissions import Permissions
1718
from dojo.importers.auto_create_context import AutoCreateContextManager
@@ -872,6 +873,11 @@ def has_permission(self, request, view):
872873
return request.user and request.user.is_superuser
873874

874875

876+
class IsSuperUserOrGlobalOwner(permissions.BasePermission):
877+
def has_permission(self, request, view):
878+
return user_is_superuser_or_global_owner(request.user)
879+
880+
875881
class UserHasEngagementPresetPermission(permissions.BasePermission):
876882
def has_permission(self, request, view):
877883
return check_post_permission(

dojo/api_v2/serializers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,8 @@ class UserSerializer(serializers.ModelSerializer):
487487
date_joined = serializers.DateTimeField(read_only=True)
488488
last_login = serializers.DateTimeField(read_only=True, allow_null=True)
489489
email = serializers.EmailField(required=True)
490+
token_last_reset = serializers.SerializerMethodField()
491+
password_last_reset = serializers.SerializerMethodField()
490492
password = serializers.CharField(
491493
write_only=True,
492494
style={"input_type": "password"},
@@ -515,10 +517,22 @@ class Meta:
515517
"last_login",
516518
"is_active",
517519
"is_superuser",
520+
"token_last_reset",
521+
"password_last_reset",
518522
"password",
519523
"configuration_permissions",
520524
)
521525

526+
@extend_schema_field(serializers.DateTimeField(allow_null=True))
527+
def get_token_last_reset(self, instance):
528+
uci = getattr(instance, "usercontactinfo", None)
529+
return getattr(uci, "token_last_reset", None)
530+
531+
@extend_schema_field(serializers.DateTimeField(allow_null=True))
532+
def get_password_last_reset(self, instance):
533+
uci = getattr(instance, "usercontactinfo", None)
534+
return getattr(uci, "password_last_reset", None)
535+
522536
def to_representation(self, instance):
523537
ret = super().to_representation(instance)
524538

dojo/api_v2/views.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@
171171
from dojo.risk_acceptance.queries import get_authorized_risk_acceptances
172172
from dojo.test.queries import get_authorized_test_imports, get_authorized_tests
173173
from dojo.tool_product.queries import get_authorized_tool_product_settings
174+
from dojo.user.authentication import reset_token_for_user
174175
from dojo.user.utils import get_configuration_permissions_codenames
175176
from dojo.utils import (
176177
async_delete,
@@ -2410,6 +2411,19 @@ def destroy(self, request, *args, **kwargs):
24102411
self.perform_destroy(instance)
24112412
return Response(status=status.HTTP_204_NO_CONTENT)
24122413

2414+
@action(
2415+
detail=True,
2416+
methods=["post"],
2417+
url_path="reset_api_token",
2418+
permission_classes=(IsAuthenticated, permissions.IsSuperUserOrGlobalOwner),
2419+
filter_backends=[],
2420+
pagination_class=None,
2421+
)
2422+
def reset_api_token(self, request, pk=None):
2423+
target_user = self.get_object()
2424+
reset_token_for_user(acting_user=request.user, target_user=target_user)
2425+
return Response(status=status.HTTP_204_NO_CONTENT)
2426+
24132427

24142428
# Authorization: superuser
24152429
@extend_schema_view(**schema_with_prefetch())

dojo/authorization/authorization.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ def user_has_configuration_permission(user, permission):
4040
return user.has_perm(permission)
4141

4242

43+
def user_is_superuser_or_global_owner(user):
44+
"""
45+
Returns True if the user is a superuser or has a global role (directly or
46+
via group membership) whose Role.is_owner is True.
47+
"""
48+
if not user or getattr(user, "is_anonymous", False):
49+
return False
50+
51+
if user.is_superuser:
52+
return True
53+
54+
if (
55+
hasattr(user, "global_role")
56+
and user.global_role.role is not None
57+
and user.global_role.role.is_owner
58+
):
59+
return True
60+
61+
for group in get_groups(user):
62+
if (
63+
hasattr(group, "global_role")
64+
and group.global_role.role is not None
65+
and group.global_role.role.is_owner
66+
):
67+
return True
68+
69+
return False
70+
71+
4372
def user_has_permission(user, obj, permission):
4473
if user.is_anonymous:
4574
return False
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 3.1.13 on 2025-12-12
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("dojo", "0248_alter_general_survey_expiration"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="usercontactinfo",
15+
name="token_last_reset",
16+
field=models.DateTimeField(
17+
blank=True,
18+
help_text="Timestamp of the most recent API token reset for this user.",
19+
null=True,
20+
),
21+
),
22+
migrations.AddField(
23+
model_name="usercontactinfo",
24+
name="password_last_reset",
25+
field=models.DateTimeField(
26+
blank=True,
27+
help_text="Timestamp of the most recent password reset for this user.",
28+
null=True,
29+
),
30+
),
31+
]
32+
33+

dojo/forms.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from tagulous.forms import TagField
3232

3333
import dojo.jira_link.helper as jira_helper
34-
from dojo.authorization.authorization import user_has_configuration_permission
34+
from dojo.authorization.authorization import user_has_configuration_permission, user_is_superuser_or_global_owner
3535
from dojo.authorization.roles_permissions import Permissions
3636
from dojo.endpoint.utils import endpoint_filter, endpoint_get_or_create, validate_endpoints_to_add
3737
from dojo.engagement.queries import get_authorized_engagements
@@ -2420,13 +2420,32 @@ class Meta:
24202420

24212421

24222422
class UserContactInfoForm(forms.ModelForm):
2423+
reset_api_token = forms.BooleanField(
2424+
required=False,
2425+
label=_("Reset API token"),
2426+
help_text=_("Upon saving, a new token will be generated and a notification of category 'Other' is triggered."),
2427+
)
2428+
24232429
class Meta:
24242430
model = UserContactInfo
24252431
exclude = ["user", "slack_user_id"]
2432+
# Swap order: password_last_reset before token_last_reset
2433+
field_order = [
2434+
"title", "phone_number", "cell_number", "twitter_username", "github_username",
2435+
"slack_username", "block_execution", "force_password_reset", "reset_api_token",
2436+
"password_last_reset", "token_last_reset",
2437+
]
24262438

24272439
def __init__(self, *args, **kwargs):
24282440
user = kwargs.pop("user", None)
24292441
super().__init__(*args, **kwargs)
2442+
# Make timestamp fields readonly.
2443+
# NOTE: `disabled=True` is enforced server-side by Django forms: posted values for disabled fields
2444+
# are ignored during binding/cleaning, so these timestamps cannot be modified via this form.
2445+
if "password_last_reset" in self.fields:
2446+
self.fields["password_last_reset"].disabled = True
2447+
if "token_last_reset" in self.fields:
2448+
self.fields["token_last_reset"].disabled = True
24302449
# Do not expose force password reset if the current user does not have a password to reset
24312450
if user is not None:
24322451
if not user.has_usable_password():
@@ -2437,11 +2456,15 @@ def __init__(self, *args, **kwargs):
24372456
if not current_user.is_superuser:
24382457
if not user_has_configuration_permission(current_user, "auth.change_user") and \
24392458
not user_has_configuration_permission(current_user, "auth.add_user"):
2440-
del self.fields["force_password_reset"]
2459+
self.fields.pop("force_password_reset", None)
24412460
if not get_system_setting("enable_user_profile_editable"):
24422461
for field in self.fields:
24432462
self.fields[field].disabled = True
24442463

2464+
# Only show reset_api_token to superusers or global owners, and only if API tokens are enabled
2465+
if not settings.API_TOKENS_ENABLED or not user_is_superuser_or_global_owner(current_user):
2466+
self.fields.pop("reset_api_token", None)
2467+
24452468

24462469
class GlobalRoleForm(forms.ModelForm):
24472470
class Meta:
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.contrib.auth import get_user_model
2+
from django.core.management.base import BaseCommand, CommandError
3+
4+
from dojo.user.authentication import reset_token_for_user
5+
6+
7+
class Command(BaseCommand):
8+
help = "Rotate (reset) the DRF API token for a target user. Requires an acting user (superuser or global owner)."
9+
10+
def add_arguments(self, parser):
11+
parser.add_argument("--acting-user", required=True, help="Username of the acting user performing the reset.")
12+
parser.add_argument("--username", help="Username of the target user.")
13+
parser.add_argument("--user-id", type=int, help="ID of the target user.")
14+
15+
def handle(self, *args, **options):
16+
User = get_user_model()
17+
18+
acting_username = options["acting_user"]
19+
target_username = options.get("username")
20+
target_user_id = options.get("user_id")
21+
22+
if bool(target_username) == bool(target_user_id):
23+
msg = "Provide exactly one of --username or --user-id."
24+
raise CommandError(msg)
25+
26+
try:
27+
acting_user = User.objects.get(username=acting_username)
28+
except User.DoesNotExist as exc:
29+
msg = f"Acting user '{acting_username}' does not exist."
30+
raise CommandError(msg) from exc
31+
32+
try:
33+
if target_username:
34+
target_user = User.objects.get(username=target_username)
35+
else:
36+
target_user = User.objects.get(id=target_user_id)
37+
except User.DoesNotExist as exc:
38+
msg = "Target user does not exist."
39+
raise CommandError(msg) from exc
40+
41+
try:
42+
reset_token_for_user(acting_user=acting_user, target_user=target_user)
43+
except Exception as exc:
44+
raise CommandError(str(exc)) from exc
45+
46+
self.stdout.write(self.style.SUCCESS("API token reset successfully."))

dojo/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ class UserContactInfo(models.Model):
268268
slack_user_id = models.CharField(blank=True, null=True, max_length=25)
269269
block_execution = models.BooleanField(default=False, help_text=_("Instead of async deduping a finding the findings will be deduped synchronously and will 'block' the user until completion."))
270270
force_password_reset = models.BooleanField(default=False, help_text=_("Forces this user to reset their password on next login."))
271+
token_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent API token reset for this user."))
272+
password_last_reset = models.DateTimeField(null=True, blank=True, help_text=_("Timestamp of the most recent password reset for this user."))
271273

272274

273275
class Dojo_Group(models.Model):

0 commit comments

Comments
 (0)