From d93f7fdfb563c37b66f07893ba353c85898744d9 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 13 Jul 2026 21:49:46 +0200 Subject: [PATCH] Add Discord earned role assignment audit log --- backend/CLAUDE.md | 1 + backend/social_connections/admin.py | 29 ++++++++++++++++ backend/social_connections/discord_roles.py | 16 +++++++-- backend/social_connections/earned_roles.py | 25 ++++++++++++-- .../0006_discordearnedroleassignment.py | 33 +++++++++++++++++++ backend/social_connections/models.py | 28 ++++++++++++++++ .../tests/test_earned_roles.py | 26 ++++++++++++++- 7 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 backend/social_connections/migrations/0006_discordearnedroleassignment.py diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 02859662..5231a82c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -348,6 +348,7 @@ backend/ ### Earned Discord Roles (social_connections) - **Logic**: `social_connections/earned_roles.py:assign_earned_community_roles(dry_run=False)` assigns the Synapse (14,000 effective CP + 8 POAPs) and Brain (80,000 effective CP + 16 POAPs + Neurocreative role held) Discord roles to qualifying users. Add-only: never removes roles; each role's threshold is evaluated independently. CP comes from `community_xp.utils.build_effective_community_scores_queryset` (MEE6 baseline + pending contribution and community social-task points), POAPs from `PoapClaim` counts, held roles from `DiscordConnection.current_roles` (the 15-min sync is the reconciler; a successful assignment also updates the local cache). Aborts the run on 429/401/403 (rate limit, missing Manage Roles, hierarchy); other per-user failures log and continue. No-op unless all three `DISCORD_*_ROLE_ID` env vars are set. +- **Audit**: `DiscordEarnedRoleAssignment` stores each successful automatic grant with the Discord member, role, CP/POAP snapshot, and creation time; records are read-only in admin. - **Service**: `DiscordRoleSyncService.add_member_role(discord_user_id, role_id)` PUTs to Discord; 404 (member left) returns False. - **Trigger**: `POST /api/v1/users/discord/assign-earned-roles/` (cron-protected, background thread + `DiscordRoleSyncLock` row `discord_earned_role_assign`), called daily at 00:30 UTC by `.github/workflows/assign-discord-roles.yml` (after the 00:00 MEE6 XP sync). Manual/backfill: `python manage.py assign_earned_discord_roles [--dry-run]`; dry run prints the would-assign list for review. - **Ops**: the bot needs Manage Roles and its role above Synapse/Brain in the guild hierarchy. diff --git a/backend/social_connections/admin.py b/backend/social_connections/admin.py index fde8c027..04c815d8 100644 --- a/backend/social_connections/admin.py +++ b/backend/social_connections/admin.py @@ -2,6 +2,7 @@ from .models import ( DiscordConnection, + DiscordEarnedRoleAssignment, DiscordRole, DiscordRoleSyncLock, GitHubConnection, @@ -94,6 +95,34 @@ class DiscordRoleSyncLockAdmin(admin.ModelAdmin): readonly_fields = ('name', 'owner_token', 'acquired_at', 'heartbeat_at', 'released_at') +@admin.register(DiscordEarnedRoleAssignment) +class DiscordEarnedRoleAssignmentAdmin(admin.ModelAdmin): + list_select_related = ('connection__user',) + list_display = ('created_at', 'role_name', 'discord_username', 'connection', 'total_points', 'poap_count') + list_filter = ('role_name',) + search_fields = ('discord_username', 'discord_user_id', 'connection__user__email') + date_hierarchy = 'created_at' + readonly_fields = ( + 'connection', + 'discord_user_id', + 'discord_username', + 'role_id', + 'role_name', + 'total_points', + 'poap_count', + 'created_at', + ) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.register(PendingOAuthState) class PendingOAuthStateAdmin(admin.ModelAdmin): list_display = ('platform', 'user', 'created_at', 'consumed_at') diff --git a/backend/social_connections/discord_roles.py b/backend/social_connections/discord_roles.py index 221270fe..e7ab603a 100644 --- a/backend/social_connections/discord_roles.py +++ b/backend/social_connections/discord_roles.py @@ -3,6 +3,7 @@ import time from dataclasses import dataclass from datetime import timedelta, timezone as datetime_timezone +from urllib.parse import quote import requests from django.conf import settings @@ -77,7 +78,7 @@ def _parse_retry_after(self, response): except (TypeError, ValueError): return None - def _request(self, method, path, trace_name, retry_once=True): + def _request(self, method, path, trace_name, retry_once=True, audit_log_reason=None): self._ensure_configured() url = f"{self.api_base_url}{path}" @@ -85,6 +86,8 @@ def _request(self, method, path, trace_name, retry_once=True): 'Authorization': f'Bot {self.bot_token}', 'Accept': 'application/json', } + if audit_log_reason: + headers['X-Audit-Log-Reason'] = quote(audit_log_reason, safe='') try: with trace_external('discord', trace_name): @@ -109,7 +112,13 @@ def _request(self, method, path, trace_name, retry_once=True): retry_after = self._parse_retry_after(response) if retry_after is not None and retry_after <= 2: time.sleep(retry_after) - return self._request(method, path, trace_name, retry_once=False) + return self._request( + method, + path, + trace_name, + retry_once=False, + audit_log_reason=audit_log_reason, + ) raise DiscordRoleSyncUnavailable( 'Discord rate limit exceeded', status_code=429, @@ -285,12 +294,13 @@ def sync_member_roles(self, connection, sync_catalog=True): connection.current_roles.set(roles) return MemberRoleSyncResult(connection=connection, is_member=True) - def add_member_role(self, discord_user_id, role_id): + def add_member_role(self, discord_user_id, role_id, audit_log_reason=None): """Assign one guild role to a member. Returns False if the member left.""" response = self._request( 'PUT', f'/guilds/{self.guild_id}/members/{discord_user_id}/roles/{role_id}', 'add_member_role', + audit_log_reason=audit_log_reason, ) if response.status_code == 404: diff --git a/backend/social_connections/earned_roles.py b/backend/social_connections/earned_roles.py index f6d52beb..396bafb4 100644 --- a/backend/social_connections/earned_roles.py +++ b/backend/social_connections/earned_roles.py @@ -5,6 +5,7 @@ """ from django.conf import settings +from django.db import transaction from django.db.models import Count from tally.middleware.logging_utils import get_app_logger @@ -14,7 +15,7 @@ DiscordRoleSyncService, DiscordRoleSyncUnavailable, ) -from .models import DiscordConnection +from .models import DiscordConnection, DiscordEarnedRoleAssignment logger = get_app_logger('discord_roles') @@ -120,7 +121,15 @@ def assign_earned_community_roles(dry_run=False, service=None): for label, role_id in _wanted_roles(role_ids, total_points, poap_count, held): if not dry_run: try: - assigned = service.add_member_role(connection.platform_user_id, role_id) + audit_reason = ( + f'Automatic earned role assignment: {label} ' + f'({total_points} CP, {poap_count} POAPs)' + ) + assigned = service.add_member_role( + connection.platform_user_id, + role_id, + audit_log_reason=audit_reason, + ) except DiscordRoleSyncConfigurationError as exc: logger.warning("Earned role assignment aborted: %s", exc) stats['errors'] += 1 @@ -144,7 +153,17 @@ def assign_earned_community_roles(dry_run=False, service=None): stats['skipped_not_member'] += 1 continue - connection.current_roles.add(*service._get_or_create_missing_roles([role_id])) + with transaction.atomic(): + connection.current_roles.add(*service._get_or_create_missing_roles([role_id])) + DiscordEarnedRoleAssignment.objects.create( + connection=connection, + discord_user_id=connection.platform_user_id, + discord_username=connection.platform_username, + role_id=role_id, + role_name=label, + total_points=total_points, + poap_count=poap_count, + ) logger.info( "Assigned %s role to user %s (discord %s): %s CP, %s POAPs", label, diff --git a/backend/social_connections/migrations/0006_discordearnedroleassignment.py b/backend/social_connections/migrations/0006_discordearnedroleassignment.py new file mode 100644 index 00000000..4f632e1b --- /dev/null +++ b/backend/social_connections/migrations/0006_discordearnedroleassignment.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.6 on 2026-07-13 19:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('social_connections', '0005_pendingoauthstate_session_key'), + ] + + operations = [ + migrations.CreateModel( + name='DiscordEarnedRoleAssignment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('discord_user_id', models.CharField(db_index=True, max_length=100)), + ('discord_username', models.CharField(blank=True, max_length=100)), + ('role_id', models.CharField(db_index=True, max_length=100)), + ('role_name', models.CharField(max_length=100)), + ('total_points', models.PositiveIntegerField()), + ('poap_count', models.PositiveIntegerField()), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('connection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='earned_role_assignments', to='social_connections.discordconnection')), + ], + options={ + 'db_table': 'social_connections_discord_earned_role_assignment', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/social_connections/models.py b/backend/social_connections/models.py index f9e3eb5e..c070d974 100644 --- a/backend/social_connections/models.py +++ b/backend/social_connections/models.py @@ -6,6 +6,8 @@ from django.db import models from django.utils import timezone +from utils.models import BaseModel + logger = logging.getLogger(__name__) @@ -143,6 +145,32 @@ def __str__(self): return f"DiscordRoleSyncLock({self.name}, acquired={self.acquired_at})" +class DiscordEarnedRoleAssignment(BaseModel): + """Append-only record of an earned Discord role granted by the portal.""" + + connection = models.ForeignKey( + DiscordConnection, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='earned_role_assignments', + ) + discord_user_id = models.CharField(max_length=100, db_index=True) + discord_username = models.CharField(max_length=100, blank=True) + role_id = models.CharField(max_length=100, db_index=True) + role_name = models.CharField(max_length=100) + total_points = models.PositiveIntegerField() + poap_count = models.PositiveIntegerField() + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + db_table = 'social_connections_discord_earned_role_assignment' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.discord_username or self.discord_user_id}: {self.role_name}" + + class PendingOAuthState(models.Model): """Short-lived OAuth state data stored server-side for multi-worker safety.""" diff --git a/backend/social_connections/tests/test_earned_roles.py b/backend/social_connections/tests/test_earned_roles.py index 85bbe936..f4c2e9cc 100644 --- a/backend/social_connections/tests/test_earned_roles.py +++ b/backend/social_connections/tests/test_earned_roles.py @@ -22,7 +22,11 @@ SYNAPSE_POAPS, assign_earned_community_roles, ) -from social_connections.models import DiscordConnection, DiscordRole +from social_connections.models import ( + DiscordConnection, + DiscordEarnedRoleAssignment, + DiscordRole, +) from social_tasks.models import SocialTask, SocialTaskCompletion from users.models import User @@ -62,6 +66,17 @@ def test_204_assigns(self, mock_request): 'https://discord.com/api/v10/guilds/guild-1/members/user-1/roles/role-1', ) + @patch('social_connections.discord_roles.requests.request') + def test_sends_audit_log_reason(self, mock_request): + mock_request.return_value = mock_response(204) + + self.service.add_member_role('user-1', 'role-1', audit_log_reason='Earned role: synapse') + + self.assertEqual( + mock_request.call_args.kwargs['headers']['X-Audit-Log-Reason'], + 'Earned%20role%3A%20synapse', + ) + @patch('social_connections.discord_roles.requests.request') def test_404_returns_false(self, mock_request): mock_request.return_value = mock_response(404) @@ -148,6 +163,14 @@ def test_assigns_synapse_at_thresholds(self, mock_request): self.assertIn(f'discord-{user.id}', args_url) self.assertTrue(connection.current_roles.filter(role_id='role-synapse').exists()) self.assertEqual(stats['assignments'][0]['role'], 'synapse') + assignment = DiscordEarnedRoleAssignment.objects.get() + self.assertEqual(assignment.connection, connection) + self.assertEqual(assignment.discord_user_id, f'discord-{user.id}') + self.assertEqual(assignment.role_id, 'role-synapse') + self.assertEqual(assignment.role_name, 'synapse') + self.assertEqual(assignment.total_points, SYNAPSE_CP) + self.assertEqual(assignment.poap_count, SYNAPSE_POAPS) + self.assertIsNotNone(assignment.created_at) @patch('social_connections.discord_roles.requests.request') def test_pending_social_task_points_count_toward_synapse(self, mock_request): @@ -265,6 +288,7 @@ def test_dry_run_makes_no_requests(self, mock_request): self.assertEqual(len(stats['assignments']), 1) mock_request.assert_not_called() self.assertFalse(connection.current_roles.filter(role_id='role-synapse').exists()) + self.assertFalse(DiscordEarnedRoleAssignment.objects.exists()) @patch('social_connections.discord_roles.requests.request') def test_unconfigured_role_ids_noop(self, mock_request):