diff --git a/.gitignore b/.gitignore index 5e38cad85..c7d1b9233 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__ .env +api/static api/scorer/bin/ api/scorer/lib/ api/scorer/pyvenv.cfg diff --git a/api/.env-sample b/api/.env-sample index 0d28a3f9a..8452cadec 100644 --- a/api/.env-sample +++ b/api/.env-sample @@ -1,6 +1,6 @@ DEBUG=on SECRET_KEY=this_should_be_a_super_secret_key -DATABASE_URL=sqlite:////db.sqlite3 +DATABASE_URL=sqlite:///db.sqlite3 DATABASE_URL_FOR_DOCKER=postgres://passport_scorer:passport_scorer_pwd@postgres:5432/passport_scorer ALLOWED_HOSTS='[]' TEST_MNEMONIC=test val is here ... diff --git a/api/account/admin.py b/api/account/admin.py index a17ec7f98..fee6be3ec 100644 --- a/api/account/admin.py +++ b/api/account/admin.py @@ -97,6 +97,10 @@ def edit_selected(modeladmin, request, queryset): actions = [edit_selected] +class APIKeyPermissionsAdmin(admin.ModelAdmin): + list_display = ("id", "submit_passports", "read_scores", "create_scorers") + + admin.site.register(Account, AccountAdmin) admin.site.register(Community, CommunityAdmin) admin.site.register(AccountAPIKey, AccountAPIKeyAdmin) diff --git a/api/account/api.py b/api/account/api.py index 8b5124353..71eed0e21 100644 --- a/api/account/api.py +++ b/api/account/api.py @@ -80,6 +80,11 @@ class CommunityExistsException(APIException): default_detail = "A community with this name already exists" +class CommunityExistsExceptionExternalId(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "A community with this external id already exists" + + class CommunityHasNoNameException(APIException): status_code = status.HTTP_422_UNPROCESSABLE_ENTITY default_detail = "A community must have a name" @@ -282,10 +287,54 @@ class CommunitiesPayload(Schema): name: str description: str use_case: str - rule: str = Rules.LIFO + rule = Rules.LIFO scorer: str +def create_community_for_account( + account, + payload, + limit, + default_scorer, + use_case="Sybil Protection", + rule=Rules.LIFO, + external_scorer_id=None, +): + account_communities = Community.objects.filter(account=account, deleted_at=None) + + if account_communities.count() >= limit: + raise TooManyCommunitiesException() + + if account_communities.filter(name=payload.name).exists(): + raise CommunityExistsException() + + if len(payload.name) == 0: + raise CommunityHasNoNameException() + + if ( + external_scorer_id + and account_communities.filter(external_scorer_id=external_scorer_id).exists() + ): + raise CommunityExistsExceptionExternalId() + + # Create the scorer + scorer = default_scorer() + scorer.save() + + # Create the community + community = Community.objects.create( + account=account, + name=payload.name, + description=payload.description, + use_case=use_case, + rule=rule, + scorer=scorer, + external_scorer_id=external_scorer_id, + ) + + return community + + @api.post("/communities", auth=JWTAuth()) def create_community(request, payload: CommunitiesPayload): try: @@ -293,43 +342,28 @@ def create_community(request, payload: CommunitiesPayload): if payload == None: raise CommunityHasNoBodyException() - account_communities = Community.objects.filter(account=account, deleted_at=None) - - if account_communities.count() >= 5: - raise TooManyCommunitiesException() - - if account_communities.filter(name=payload.name).exists(): - raise CommunityExistsException() - - if len(payload.name) == 0: - raise CommunityHasNoNameException() - if len(payload.description) == 0: raise CommunityHasNoDescriptionException() - scorer = None - - if payload.scorer == "WEIGHTED_BINARY": - scorer = BinaryWeightedScorer(type="WEIGHTED_BINARY") - else: - scorer = WeightedScorer() - - scorer.save() - - Community.objects.create( - account=account, - name=payload.name, - description=payload.description, + scorer_class = ( + BinaryWeightedScorer + if payload.scorer == "WEIGHTED_BINARY" + else WeightedScorer + ) + create_community_for_account( + account, + payload, + 5, + scorer_class, use_case=payload.use_case, rule=payload.rule, - scorer=scorer, ) + return {"ok": True} + except Account.DoesNotExist: raise UnauthorizedException() - return {"ok": True} - @api.get("/communities", auth=JWTAuth(), response=List[CommunityApiSchema]) def get_communities(request): diff --git a/api/account/migrations/0016_accountapikey_create_scorers_and_more.py b/api/account/migrations/0016_accountapikey_create_scorers_and_more.py new file mode 100644 index 000000000..c5062493e --- /dev/null +++ b/api/account/migrations/0016_accountapikey_create_scorers_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.1 on 2023-05-12 08:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0015_alter_accountapikey_rate_limit"), + ] + + operations = [ + migrations.AddField( + model_name="accountapikey", + name="create_scorers", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="accountapikey", + name="read_scores", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="accountapikey", + name="submit_passports", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="community", + name="external_scorer_id", + field=models.CharField(blank=True, max_length=42, null=True, unique=True), + ), + ] diff --git a/api/account/models.py b/api/account/models.py index 0b06d0594..de837b97f 100644 --- a/api/account/models.py +++ b/api/account/models.py @@ -106,6 +106,10 @@ class AccountAPIKey(AbstractAPIKey): blank=True, ) + submit_passports = models.BooleanField(default=True) + read_scores = models.BooleanField(default=True) + create_scorers = models.BooleanField(default=False) + def rate_limit_display(self): if self.rate_limit == "" or self.rate_limit is None: return "Unlimited" @@ -158,6 +162,10 @@ class Meta: help_text="The use case that the creator of this community (Scorer) would like to cover", ) + external_scorer_id = models.CharField( + max_length=42, unique=True, null=True, blank=True + ) + def __repr__(self): return f"" diff --git a/api/account/test/test_api_key.py b/api/account/test/test_api_key.py index 91e2927f4..42da8b800 100644 --- a/api/account/test/test_api_key.py +++ b/api/account/test/test_api_key.py @@ -166,12 +166,17 @@ def test_delete_api_key_with_slash_in_id(self): # Create API key for first account (account_api_key, secret) = AccountAPIKey.objects.create_key( - account=self.account, name="Token for user 1" + account=self.account, + name="Token for user 2", ) + modified_api_key = account_api_key.id.replace(account_api_key.id[0:3], "ABC") + modified_prefix = modified_api_key.split(".")[0] + # Forcefully add a "/" - account_api_key.id = f"PRE{account_api_key.id}/SJF" - account_api_key.prefix = f"PRE{account_api_key.prefix}" + account_api_key.id = f"{modified_api_key}/SJF" + account_api_key.prefix = modified_prefix + account_api_key.save() client = Client() diff --git a/api/registry/api/schema.py b/api/registry/api/schema.py index f65375411..4b5197c7a 100644 --- a/api/registry/api/schema.py +++ b/api/registry/api/schema.py @@ -41,6 +41,12 @@ class CursorPaginatedStampCredentialResponse(Schema): items: List[StampCredentialResponse] +class GenericCommunityResponse(Schema): + ok: bool + scorer_id: str + external_scorer_id: str + + class DetailedScoreResponse(Schema): address: str score: Optional[str] @@ -78,3 +84,9 @@ class SigningMessageResponse(Schema): class ErrorMessageResponse(Schema): detail: str + + +class GenericCommunityPayload(Schema): + name: str + description: str = "Programmatically created by Allo" + external_scorer_id: str diff --git a/api/registry/api/v1.py b/api/registry/api/v1.py index 5b6bba067..22c437590 100644 --- a/api/registry/api/v1.py +++ b/api/registry/api/v1.py @@ -1,10 +1,12 @@ from typing import List import api_logging as logging +from account.api import UnauthorizedException, create_community_for_account # --- Deduplication Modules -from account.models import Account, Community, Nonce +from account.models import Account, Community, Nonce, Rules, WeightedScorer from ceramic_cache.models import CeramicCache +from django.conf import settings from django.shortcuts import get_object_or_404 from ninja import Router from ninja.pagination import paginate @@ -20,6 +22,7 @@ ) from ..exceptions import ( + InvalidAPIKeyPermissions, InvalidCommunityScoreRequestException, InvalidLimitException, InvalidNonceException, @@ -33,6 +36,8 @@ CursorPaginatedStampCredentialResponse, DetailedScoreResponse, ErrorMessageResponse, + GenericCommunityPayload, + GenericCommunityResponse, SigningMessageResponse, SubmitPassportPayload, ) @@ -43,6 +48,8 @@ analytics_router = Router() +feature_flag_router = Router() + @router.get( "/signing-message", @@ -88,6 +95,9 @@ def submit_passport(request, payload: SubmitPassportPayload) -> DetailedScoreRes account = request.auth + if not request.api_key.submit_passports: + raise InvalidAPIKeyPermissions() + return handle_submit_passport(payload, account) @@ -157,6 +167,10 @@ def handle_submit_passport( def get_score(request, address: str, scorer_id: int) -> DetailedScoreResponse: check_rate_limit(request) account = request.auth + + if not request.api_key.read_scores: + raise InvalidAPIKeyPermissions() + return handle_get_score(address, scorer_id, account) @@ -205,6 +219,9 @@ def get_scores( if kwargs["pagination_info"].limit > 1000: raise InvalidLimitException() + if not request.api_key.read_scores: + raise InvalidAPIKeyPermissions() + # Get community object user_community = api_get_object_or_404( Community, id=scorer_id, account=request.auth @@ -308,6 +325,44 @@ def get_passport_stamps( return response +@feature_flag_router.post( + "/scorer/generic", + auth=ApiKey(), + response={ + 200: GenericCommunityResponse, + 400: ErrorMessageResponse, + 401: ErrorMessageResponse, + }, + summary="Programmatically create a generic scorer", + description="""This endpoint allows the creation of new scorers.\n +You must have the correct permissions to make requests to this endpoint.\n +Anyone can go to https://www.scorer.gitcoin.co/ and create a new scorer via the UI.\n +""", +) +def create_generic_scorer(request, payload: GenericCommunityPayload): + try: + account = request.auth + if not request.api_key.create_scorers: + raise InvalidAPIKeyPermissions() + + community = create_community_for_account( + account, + payload, + settings.GENERIC_COMMUNITY_CREATION_LIMIT, + WeightedScorer, + external_scorer_id=payload.external_scorer_id, + ) + + return { + "ok": True, + "scorer_id": community.pk, + "external_scorer_id": community.external_scorer_id, + } + + except Account.DoesNotExist: + raise UnauthorizedException() + + @analytics_router.get("/score/", auth=ApiKey(), response=CursorPaginatedScoreResponse) @permissions_required([ResearcherPermission]) def get_scores_analytics( diff --git a/api/registry/exceptions.py b/api/registry/exceptions.py index d4da933fc..0abacb6ca 100644 --- a/api/registry/exceptions.py +++ b/api/registry/exceptions.py @@ -63,6 +63,11 @@ class NotFoundApiException(APIException): default_detail = "Not found." +class InvalidAPIKeyPermissions(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = "Invalid permissions for action." + + def api_get_object_or_404(klass, *args, **kwargs): """ Note: this is an adjusted clone of djangos get_object_or_404 diff --git a/api/registry/test/conftest.py b/api/registry/test/conftest.py index ecbe24802..9de588917 100644 --- a/api/registry/test/conftest.py +++ b/api/registry/test/conftest.py @@ -3,6 +3,7 @@ passport_holder_addresses, scorer_account, scorer_api_key, + scorer_api_key_no_permissions, scorer_community, scorer_passport, scorer_score, diff --git a/api/registry/test/generic_scorer_creation.py b/api/registry/test/generic_scorer_creation.py new file mode 100644 index 000000000..cdb02f641 --- /dev/null +++ b/api/registry/test/generic_scorer_creation.py @@ -0,0 +1,125 @@ +import json + +import pytest +from account.models import AccountAPIKey, Community, Rules +from django.test import Client +from django.urls import reverse + +client = Client() + +pytestmark = pytest.mark.django_db + + +def test_create_generic_scorer_success(scorer_account): + (_, secret) = AccountAPIKey.objects.create_key( + account=scorer_account, + name="Test API key", + ) + + payload = {"name": "Test Community", "external_scorer_id": "0x0000"} + + response = client.post( + "/registry/scorer/generic", + json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Token {secret}", + ) + response_data = response.json() + + assert response.status_code == 200 + assert response_data["ok"] == True + assert "scorer_id" in response_data + assert "external_scorer_id" in response_data + + # Verify the community was created in the database + community = Community.objects.get(pk=response_data["scorer_id"]) + assert community.name == payload["name"] + assert community.account == scorer_account + + +def test_create_generic_scorer_no_permission(scorer_account): + (_, secret) = AccountAPIKey.objects.create_key( + account=scorer_account, name="Test API key" + ) + + payload = {"name": "Test Community", "external_scorer_id": "0x0000"} + + response = client.post( + "/registry/scorer/generic", + json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Token {secret}", + ) + + assert response.status_code == 403 + + +def test_create_generic_scorer_too_many_communities(scorer_account, settings): + (_, secret) = AccountAPIKey.objects.create_key( + account=scorer_account, + name="Test API key", + ) + + settings.GENERIC_COMMUNITY_CREATION_LIMIT = 1 + + Community.objects.create( + account=scorer_account, + name="Existing Community", + description="Test", + use_case="Sybil Protection", + rule=Rules.LIFO, + ) + + payload = {"name": "Test Community", "external_scorer_id": "0x0000"} + + response = client.post( + "/registry/scorer/generic", + json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Token {secret}", + ) + assert response.status_code == 400 + + +def test_create_generic_scorer_duplicate_name(scorer_account): + (_, secret) = AccountAPIKey.objects.create_key( + account=scorer_account, + name="Test API key", + ) + + Community.objects.create( + account=scorer_account, + name="Test Community", + description="Test", + use_case="Sybil Protection", + rule=Rules.LIFO, + ) + + payload = {"name": "Test Community", "external_scorer_id": "0x0000"} + + response = client.post( + "/registry/scorer/generic", + json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Token {secret}", + ) + + assert response.status_code == 400 + + +def test_create_generic_scorer_no_name(scorer_account): + (_, secret) = AccountAPIKey.objects.create_key( + account=scorer_account, + name="Test API key", + ) + + payload = {"name": "", "external_scorer_id": "0x0000"} + + response = client.post( + "/registry/scorer/generic", + json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=f"Token {secret}", + ) + + assert response.status_code == 422 diff --git a/api/registry/test/test_passport_get_score.py b/api/registry/test/test_passport_get_score.py index aa7bd259a..a637a15b5 100644 --- a/api/registry/test/test_passport_get_score.py +++ b/api/registry/test/test_passport_get_score.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.test import Client +from registry import permissions from registry.models import Passport, Score from web3 import Web3 @@ -227,7 +228,6 @@ def test_get_single_score_for_address_in_path( def test_cannot_get_single_score_for_address_in_path_for_other_community( self, - scorer_api_key, passport_holder_addresses, scorer_community, paginated_scores, @@ -243,7 +243,8 @@ def test_cannot_get_single_score_for_address_in_path_for_other_community( account = Account.objects.create(user=user, address=web3_account.address) (_, api_key) = AccountAPIKey.objects.create_key( - account=account, name="Token for user 1" + account=account, + name="Token for user 1", ) client = Client() @@ -280,9 +281,32 @@ def test_limit_of_1000_is_ok( assert response.status_code == 200 - def test_cannot_get_score_for_other_community( - self, scorer_community, scorer_api_key + def test_get_single_score_for_address_without_permissions( + self, + passport_holder_addresses, + scorer_community, + scorer_api_key_no_permissions, + ): + client = Client() + response = client.get( + f"/registry/score/{scorer_community.id}/{passport_holder_addresses[0]['address']}", + HTTP_AUTHORIZATION="Token " + scorer_api_key_no_permissions, + ) + assert response.status_code == 403 + + def test_get_single_score_by_scorer_id_without_permissions( + self, + scorer_community, + scorer_api_key_no_permissions, ): + client = Client() + response = client.get( + f"/registry/score/{scorer_community.id}", + HTTP_AUTHORIZATION="Token " + scorer_api_key_no_permissions, + ) + assert response.status_code == 403 + + def test_cannot_get_score_for_other_community(self, scorer_community): """Test that a user can't get scores for a community they don't belong to.""" # Create another user, account & api key user = User.objects.create_user(username="anoter-test-user", password="12345") @@ -292,7 +316,8 @@ def test_cannot_get_score_for_other_community( account = Account.objects.create(user=user, address=web3_account.address) (_, api_key) = AccountAPIKey.objects.create_key( - account=account, name="Token for user 1" + account=account, + name="Token for user 1", ) client = Client() diff --git a/api/registry/test/test_passport_submission.py b/api/registry/test/test_passport_submission.py index d6b2d1b01..8ed510040 100644 --- a/api/registry/test/test_passport_submission.py +++ b/api/registry/test/test_passport_submission.py @@ -11,6 +11,7 @@ from django.contrib.auth.models import User from django.test import Client, TransactionTestCase from eth_account.messages import encode_defunct +from registry import permissions from registry.models import Passport, Stamp from registry.tasks import score_passport from registry.utils import get_signer, get_signing_message, verify_issuer @@ -322,7 +323,8 @@ def setUp(self): ) (account_api_key, secret) = AccountAPIKey.objects.create_key( - account=self.user_account, name="Token for user 1" + account=self.user_account, + name="Token for user 1", ) self.account_api_key = account_api_key self.secret = secret diff --git a/api/scorer/api.py b/api/scorer/api.py index eec9633ff..c6a715c64 100644 --- a/api/scorer/api.py +++ b/api/scorer/api.py @@ -4,7 +4,7 @@ from ceramic_cache.api import router as ceramic_cache_router from django_ratelimit.exceptions import Ratelimited from ninja import NinjaAPI -from registry.api.v1 import analytics_router +from registry.api.v1 import analytics_router, feature_flag_router from registry.api.v1 import router as registry_router_v1 from registry.api.v2 import router as registry_router_v2 @@ -29,6 +29,9 @@ def service_unavailable(request, _): "/registry/", registry_router_v1, tags=["Score your passport"] ) +feature_flag_api = NinjaAPI(urls_namespace="feature") +feature_flag_api.add_router("", feature_flag_router) + registry_api_v2.add_router("", registry_router_v2, tags=["Score your passport"]) ceramic_cache_api = NinjaAPI(urls_namespace="ceramic-cache", docs_url=None) diff --git a/api/scorer/settings/base.py b/api/scorer/settings/base.py index 2609b2b71..6d372522f 100644 --- a/api/scorer/settings/base.py +++ b/api/scorer/settings/base.py @@ -37,6 +37,10 @@ SECURE_SSL_REDIRECT = env("SECURE_SSL_REDIRECT", default=False) SECURE_PROXY_SSL_HEADER = env.json("SECURE_PROXY_SSL_HEADER", default=None) +GENERIC_COMMUNITY_CREATION_LIMIT = env.int( + "GENERIC_COMMUNITY_CREATION_LIMIT", default=5 +) + FF_API_ANALYTICS = env("FF_API_ANALYTICS", default="Off") LOGGING_STRATEGY = env( "LOGGING_STRATEGY", default="default" diff --git a/api/scorer/test/conftest.py b/api/scorer/test/conftest.py index 87e7f28b3..b95ad6716 100644 --- a/api/scorer/test/conftest.py +++ b/api/scorer/test/conftest.py @@ -61,7 +61,20 @@ def passport_holder_addresses(): @pytest.fixture def scorer_api_key(scorer_account): (_, secret) = AccountAPIKey.objects.create_key( - account=scorer_account, name="Token for user 1", rate_limit="3/30seconds" + account=scorer_account, + name="Token for user 1", + rate_limit="3/30seconds", + ) + return secret + + +@pytest.fixture +def scorer_api_key_no_permissions(scorer_account): + (_, secret) = AccountAPIKey.objects.create_key( + account=scorer_account, + name="Token for user 1", + rate_limit="3/30seconds", + read_scores=False, ) return secret diff --git a/api/scorer/urls.py b/api/scorer/urls.py index 926bfadb1..b482546e1 100644 --- a/api/scorer/urls.py +++ b/api/scorer/urls.py @@ -19,11 +19,18 @@ from django.contrib.auth import views as auth_views from django.urls import include, path -from .api import analytics_api, ceramic_cache_api, registry_api_v1, registry_api_v2 +from .api import ( + analytics_api, + ceramic_cache_api, + feature_flag_api, + registry_api_v1, + registry_api_v2, +) urlpatterns = [ path("", registry_api_v1.urls), path("registry/v2/", registry_api_v2.urls), + path("registry/feature/", feature_flag_api.urls), path("ceramic-cache/", ceramic_cache_api.urls), path("analytics/", analytics_api.urls), path("health/", health, {}, "health-check"),