Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -655,3 +655,4 @@ The project uses **context-aware serialization** to optimize API performance:
- URL: `/admin/`
- Requires superuser account
- Models registered in `{app}/admin.py`
- User records include a `can_view_role_sections` checkbox for read-only access to gated Builder, Validator, and Community portal views. It does not create role profiles, enable role actions, affect role metrics, or grant Steward access.
13 changes: 13 additions & 0 deletions backend/contributions/tests/test_validator_category_gating.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,19 @@ def test_user_without_creator_profile_is_blocked_from_community_category(self):
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertIn('Only creators', response.data['error'])

def test_read_only_role_access_does_not_grant_submission_permissions(self):
self.plain_user.can_view_role_sections = True
self.plain_user.save(update_fields=['can_view_role_sections', 'updated_at'])

for contribution_type in (
self.builder_type,
self.node_running_type,
self.community_type,
):
with self.subTest(category=contribution_type.category.slug):
response = self._post_submission(self.plain_user, contribution_type)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_user_with_creator_profile_passes_community_gating(self):
response = self._post_submission(self.creator_user, self.community_type)
# May be 201 or 400 depending on recaptcha config, but must not be 403
Expand Down
101 changes: 101 additions & 0 deletions backend/poaps/tests/test_poaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from rest_framework import status
from rest_framework.test import APIClient

from creators.models import Creator
from ethereum_auth.models import Nonce
from poaps.admin import PoapDistributionAdminForm, PoapDropAdmin, PoapDropAdminForm
from poaps.models import PoapClaim, PoapDistribution, PoapDrop, PoapImportBatch
Expand Down Expand Up @@ -145,6 +146,39 @@ def test_secret_claim_success(self):
distribution.refresh_from_db()
self.assertEqual(distribution.claimed_count, 1)

def test_read_only_community_viewer_cannot_claim_secret(self):
distribution = self._secret_distribution()
self.user.can_view_role_sections = True
self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
self.client.force_authenticate(user=self.user)

response = self.client.post(
'/api/v1/poaps/ama-session/claim-secret/',
{'secret': 'friend-scientist-natural'},
format='json',
)

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.data['code'], 'role_view_only')
self.assertFalse(PoapClaim.objects.filter(drop=self.drop, user=self.user).exists())
distribution.refresh_from_db()
self.assertEqual(distribution.claimed_count, 0)

def test_real_creator_with_view_flag_can_still_claim_secret(self):
self._secret_distribution()
self.user.can_view_role_sections = True
self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
Creator.objects.create(user=self.user)
self.client.force_authenticate(user=self.user)

response = self.client.post(
'/api/v1/poaps/ama-session/claim-secret/',
{'secret': 'friend-scientist-natural'},
format='json',
)

self.assertEqual(response.status_code, status.HTTP_201_CREATED)

def test_secret_claim_requires_discord_connection(self):
distribution = self._secret_distribution()
DiscordConnection.objects.filter(user=self.user).delete()
Expand Down Expand Up @@ -276,6 +310,59 @@ def test_mint_link_legacy_path_still_claims(self):
self.assertEqual(link.used_count, 1)
self.assertEqual(distribution.claimed_count, 1)

def test_read_only_community_viewer_cannot_claim_mint_links(self):
distribution = PoapDistribution.objects.create(
drop=self.drop,
method=PoapDistribution.METHOD_MINT_LINK,
active=True,
)
generated_links = generate_mint_links(distribution=distribution, count=2)
self.user.can_view_role_sections = True
self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
self.client.force_authenticate(user=self.user)

modern_link, modern_token = generated_links[0]
legacy_link, legacy_token = generated_links[1]
responses = (
self.client.post(
'/api/v1/poaps/claim-link/',
{'token': modern_token},
format='json',
),
self.client.post(f'/api/v1/poaps/claim-link/{legacy_token}/'),
)

for response in responses:
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.data['code'], 'role_view_only')
self.assertFalse(PoapClaim.objects.filter(drop=self.drop, user=self.user).exists())
modern_link.refresh_from_db()
legacy_link.refresh_from_db()
distribution.refresh_from_db()
self.assertEqual(modern_link.used_count, 0)
self.assertEqual(legacy_link.used_count, 0)
self.assertEqual(distribution.claimed_count, 0)

def test_real_creator_with_view_flag_can_claim_mint_link(self):
distribution = PoapDistribution.objects.create(
drop=self.drop,
method=PoapDistribution.METHOD_MINT_LINK,
active=True,
)
[(_link, token)] = generate_mint_links(distribution=distribution, count=1)
self.user.can_view_role_sections = True
self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
Creator.objects.create(user=self.user)
self.client.force_authenticate(user=self.user)

response = self.client.post(
'/api/v1/poaps/claim-link/',
{'token': token},
format='json',
)

self.assertEqual(response.status_code, status.HTTP_201_CREATED)

def test_mint_link_claim_reports_missing_token(self):
self.client.force_authenticate(user=self.user)

Expand Down Expand Up @@ -585,6 +672,20 @@ def test_verify_wallet_attaches_unmatched_legacy_claims_without_changing_session
self.assertEqual(claim.user, self.user)
self.assertEqual(self.client.session['ethereum_address'], self.user.address)

def test_read_only_community_viewer_cannot_recover_poaps(self):
self.user.can_view_role_sections = True
self.user.save(update_fields=['can_view_role_sections', 'updated_at'])
self.client.force_authenticate(user=self.user)

response = self.client.post(
'/api/v1/poaps/verify-wallet/',
{},
format='json',
)

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.data['code'], 'role_view_only')

def test_verify_wallet_rejects_invalid_signature(self):
account = Account.create()
signer = Account.create()
Expand Down
25 changes: 25 additions & 0 deletions backend/poaps/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from ethereum_auth.models import Nonce
from ethereum_auth.siwe_utils import get_expected_siwe_domain, get_expected_siwe_uri, normalize_origin
from users.role_access import is_role_section_read_only

from .models import PoapClaim, PoapDistribution, PoapDrop
from .serializers import (
Expand Down Expand Up @@ -93,6 +94,18 @@ class PoapDropViewSet(viewsets.ReadOnlyModelViewSet):
ordering_fields = ['event_start_at', 'created_at', 'title']
ordering = ['-event_start_at']

def _read_only_community_response(self, request):
"""Reject mutations exposed by gated Community views for viewers."""
if not is_role_section_read_only(request.user, 'community'):
return None
return Response(
{
'error': 'View-only access does not allow POAP claims or recovery.',
'code': 'role_view_only',
},
status=status.HTTP_403_FORBIDDEN,
)

def get_throttles(self):
self.throttle_scope = (
'poap_claim_secret'
Expand Down Expand Up @@ -197,6 +210,10 @@ def claims(self, request, slug=None):
permission_classes=[permissions.IsAuthenticated],
)
def claim_secret(self, request, slug=None):
read_only_response = self._read_only_community_response(request)
if read_only_response is not None:
return read_only_response

try:
claim = claim_with_secret(
drop_slug=slug,
Expand All @@ -211,6 +228,10 @@ def claim_secret(self, request, slug=None):
return Response(PoapClaimSerializer(claim).data, status=status.HTTP_201_CREATED)

def _claim_link_with_token(self, request, token):
read_only_response = self._read_only_community_response(request)
if read_only_response is not None:
return read_only_response

try:
claim = claim_with_mint_link(token=token, user=request.user)
except PoapDrop.DoesNotExist:
Expand Down Expand Up @@ -239,6 +260,10 @@ def claim_link_legacy(self, request, token=None):

@action(detail=False, methods=['post'], url_path='verify-wallet', permission_classes=[permissions.IsAuthenticated])
def verify_wallet(self, request):
read_only_response = self._read_only_community_response(request)
if read_only_response is not None:
return read_only_response

if not request.user.address:
return Response(
{'error': 'Your portal account does not have a wallet address.'},
Expand Down
45 changes: 45 additions & 0 deletions backend/social_tasks/eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

from django.core.exceptions import ValidationError

from users.role_access import (
VIEWABLE_ROLE_CATEGORIES,
is_role_section_read_only,
user_has_role_profile,
)


SUPPORTED_RULE_TYPES = {
'accepted_submittable_contribution',
Expand Down Expand Up @@ -33,6 +39,45 @@ def validate_eligibility_requirements(value):


def evaluate_task_eligibility(task, user):
"""Evaluate task rules without allowing read-only access to award points."""
category_slug = getattr(getattr(task, 'category', None), 'slug', None)

# Journey tasks remain available to ordinary pre-role users. The explicit
# admin viewer flag is different: while enabled, it must never become an
# alternate path to completing tasks in a role the user does not hold.
if (
category_slug in VIEWABLE_ROLE_CATEGORIES
and user is not None
and getattr(user, 'is_authenticated', False)
and is_role_section_read_only(user, category_slug)
):
return EligibilityResult(
False,
'View-only access does not allow task completion.',
details={
'requirements': [],
'required_role': category_slug,
'read_only': True,
},
)

# Validator tasks are never part of a pre-role journey and can award
# validator leaderboard points, so every non-validator remains ineligible.
if category_slug == 'validator':
if user is None or not getattr(user, 'is_authenticated', False):
return EligibilityResult(
False,
'Sign in with a validator account to complete this task.',
details={'requirements': [], 'required_role': 'validator'},
)

if not user_has_role_profile(user, 'validator'):
return EligibilityResult(
False,
'Only validators can complete validator tasks.',
details={'requirements': [], 'required_role': 'validator'},
)

requirements = task.eligibility_requirements or {}
normalized = _normalize_requirements(requirements)
rules = normalized['all'] + normalized['any']
Expand Down
Loading
Loading