From 42dee02e71a67ea752e321b801ca7e9b3e439805 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:42:08 +0200 Subject: [PATCH 1/7] Bump djangorestframework from 3.16.1 to 3.18.1 (#558) Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.16.1 to 3.18.1. - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.16.1...3.18.1) --- updated-dependencies: - dependency-name: djangorestframework dependency-version: 3.18.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 98b1cc86..fffd5c54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ requests-aws4auth==0.9 mock==3.0.5 base32_crockford==0.3.0 elasticsearch==7.10.1 -djangorestframework==3.16.1 +djangorestframework==3.18.1 setuptools>=40,<81 django-prometheus==2.4.1 sentry-sdk==1.45.1 From 7a111205297e58c6440af88aca66d4a99f75af44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:24:45 +0200 Subject: [PATCH 2/7] Bump bleach from 6.0.0 to 6.4.0 (#556) Bumps [bleach](https://github.com/mozilla/bleach) from 6.0.0 to 6.4.0. - [Changelog](https://github.com/mozilla/bleach/blob/main/CHANGES) - [Commits](https://github.com/mozilla/bleach/compare/v6.0.0...v6.4.0) --- updated-dependencies: - dependency-name: bleach dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fffd5c54..33197cd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,6 +25,6 @@ python-magic iso639-lang rapidfuzz==3.6.1 mysqlclient==2.2.7 -bleach==6.0.0 +bleach==6.4.0 pycountry==22.3.5 django-ses==4.8.0 From a168f8bec9c30abb11e5f485e1d3e0268a8e3026 Mon Sep 17 00:00:00 2001 From: Joseph Rhoads Date: Wed, 23 Sep 2026 10:30:23 +0200 Subject: [PATCH 3/7] Update and alphabetize dependencies in requirements.txt (#568) --- requirements.txt | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/requirements.txt b/requirements.txt index 33197cd8..73c07405 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,30 +1,30 @@ +base32_crockford==0.3.0 +bleach==6.4.0 +boto3 Django==5.2.17 +django-cors-headers==4.9.0 +django-prometheus==2.4.1 +djangorestframework==3.18.1 +django-ses==4.8.0 +elasticsearch==7.10.1 elasticsearch_dsl==7.4.1 geonamescache==1.3.0 -requests==2.32.4 -requests-aws4auth==0.9 +iso639-lang +jsonschema==3.2.0 +launchdarkly-server-sdk==7.6.1 mock==3.0.5 -base32_crockford==0.3.0 -elasticsearch==7.10.1 -djangorestframework==3.18.1 -setuptools>=40,<81 -django-prometheus==2.4.1 +mysqlclient==2.2.7 +numpy==1.26.4 +pandas==2.2.3 +pycountry==22.3.5 +python-dotenv==1.2.3 +python-magic +rapidfuzz==3.6.1 +requests==2.33.0 +requests-aws4auth==0.9 sentry-sdk==1.45.1 -python-dotenv==0.10.3 -django-cors-headers==4.9.0 -unidecode==1.1.1 +setuptools>=40,<81 statsmodels==0.14.4 -boto3 -pandas==2.2.3 -numpy==1.26.4 titlecase==2.3 +unidecode==1.1.1 update_address @ git+https://github.com/ror-community/update_address.git -launchdarkly-server-sdk==7.6.1 -jsonschema==3.2.0 -python-magic -iso639-lang -rapidfuzz==3.6.1 -mysqlclient==2.2.7 -bleach==6.4.0 -pycountry==22.3.5 -django-ses==4.8.0 From 41db1b91504cfbf6df47ab9d3c95647cb1ed910f Mon Sep 17 00:00:00 2001 From: Joseph Rhoads Date: Wed, 23 Sep 2026 12:05:31 +0200 Subject: [PATCH 4/7] Add Dependabot updates for GitHub Actions (#561) Enable weekly github-actions dependency PRs against the dev branch, alongside the existing pip ecosystem config. Co-authored-by: Cursor Agent Co-authored-by: Joseph Rhoads --- .github/dependabot.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 96b466ba..c881d741 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,4 +9,9 @@ updates: target-branch: "dev" # Labels on pull requests for version updates only labels: - - "pip dependencies" \ No newline at end of file + - "pip dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "dev" From 945070a5cb6418dfdebabb8e39b172c13e7ac1c5 Mon Sep 17 00:00:00 2001 From: Joseph Rhoads Date: Wed, 23 Sep 2026 13:58:56 +0200 Subject: [PATCH 5/7] Fix OurTokenPermission write auth (deny unset env / missing headers) (#563) Co-authored-by: Cursor Agent --- rorapi/common/views.py | 28 +++--- .../tests_unit/tests_token_permission.py | 99 +++++++++++++++++++ 2 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 rorapi/tests/tests_unit/tests_token_permission.py diff --git a/rorapi/common/views.py b/rorapi/common/views.py index 332ad9c5..439a348a 100644 --- a/rorapi/common/views.py +++ b/rorapi/common/views.py @@ -31,6 +31,7 @@ from rorapi.common.queries import search_organizations, retrieve_organization, get_ror_id from urllib.parse import urlencode +import hmac import os import update_address as ua from rorapi.management.commands.generaterorid import check_ror_id @@ -122,18 +123,23 @@ class OurTokenPermission(BasePermission): """ def has_permission(self, request, view): - has_permission = False if request.method == 'GET': - has_permission = True - else: - header_token = request.headers.get("Token", None) - header_user = request.headers.get("Route-User", None) - user = os.environ.get("ROUTE_USER") - token = os.environ.get("TOKEN") - if header_token == token and header_user == user: - has_permission = True - - return has_permission + return True + + header_token = request.headers.get("Token") + header_user = request.headers.get("Route-User") + user = os.environ.get("ROUTE_USER") + token = os.environ.get("TOKEN") + + # Deny when env credentials are unset/empty or either header is missing. + # Avoid == on None (previously None==None granted write access). + if not token or not user or not header_token or not header_user: + return False + + return ( + hmac.compare_digest(header_token, token) + and hmac.compare_digest(header_user, user) + ) class OrganizationViewSet(viewsets.ViewSet): diff --git a/rorapi/tests/tests_unit/tests_token_permission.py b/rorapi/tests/tests_unit/tests_token_permission.py new file mode 100644 index 00000000..e0d9dd4b --- /dev/null +++ b/rorapi/tests/tests_unit/tests_token_permission.py @@ -0,0 +1,99 @@ +import os +from unittest import mock + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from rorapi.common.views import OurTokenPermission + +factory = APIRequestFactory() + + +class OurTokenPermissionTestCase(SimpleTestCase): + def setUp(self): + self.permission = OurTokenPermission() + + def test_get_always_allowed(self): + request = factory.get('/v2/organizations') + self.assertTrue(self.permission.has_permission(request, None)) + + @mock.patch.dict(os.environ, {}, clear=True) + def test_post_denied_when_env_unset(self): + request = factory.post( + '/v2/organizations', + HTTP_TOKEN='token-value', + HTTP_ROUTE_USER='route-user', + ) + self.assertFalse(self.permission.has_permission(request, None)) + + @mock.patch.dict( + os.environ, + {"TOKEN": "token-value", "ROUTE_USER": "route-user"}, + clear=False, + ) + def test_post_denied_when_headers_missing(self): + request = factory.post('/v2/organizations') + self.assertFalse(self.permission.has_permission(request, None)) + + @mock.patch.dict( + os.environ, + {"TOKEN": "token-value", "ROUTE_USER": "route-user"}, + clear=False, + ) + def test_post_denied_when_token_header_missing(self): + request = factory.post( + '/v2/organizations', + HTTP_ROUTE_USER='route-user', + ) + self.assertFalse(self.permission.has_permission(request, None)) + + @mock.patch.dict( + os.environ, + {"TOKEN": "token-value", "ROUTE_USER": "route-user"}, + clear=False, + ) + def test_post_denied_when_route_user_header_missing(self): + request = factory.post( + '/v2/organizations', + HTTP_TOKEN='token-value', + ) + self.assertFalse(self.permission.has_permission(request, None)) + + @mock.patch.dict( + os.environ, + {"TOKEN": "token-value", "ROUTE_USER": "route-user"}, + clear=False, + ) + def test_post_denied_on_mismatch(self): + request = factory.post( + '/v2/organizations', + HTTP_TOKEN='wrong-token', + HTTP_ROUTE_USER='route-user', + ) + self.assertFalse(self.permission.has_permission(request, None)) + + request = factory.post( + '/v2/organizations', + HTTP_TOKEN='token-value', + HTTP_ROUTE_USER='wrong-user', + ) + self.assertFalse(self.permission.has_permission(request, None)) + + @mock.patch.dict( + os.environ, + {"TOKEN": "token-value", "ROUTE_USER": "route-user"}, + clear=False, + ) + def test_post_allowed_on_match(self): + request = factory.post( + '/v2/organizations', + HTTP_TOKEN='token-value', + HTTP_ROUTE_USER='route-user', + ) + self.assertTrue(self.permission.has_permission(request, None)) + + @mock.patch.dict(os.environ, {}, clear=True) + def test_post_denied_when_env_and_headers_both_missing(self): + """Regression: None == None previously granted write access.""" + request = factory.post('/v2/organizations') + self.assertFalse(self.permission.has_permission(request, None)) From 4d9b5c688c5bd0d7902cf6cbf01c4057b250d5cf Mon Sep 17 00:00:00 2001 From: Joseph Rhoads Date: Wed, 23 Sep 2026 16:16:57 +0200 Subject: [PATCH 6/7] Catch Elasticsearch RequestError in search_organizations (#565) Co-authored-by: Cursor Agent --- rorapi/common/queries.py | 7 ++++++- rorapi/tests/tests_unit/tests_queries_v2.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/rorapi/common/queries.py b/rorapi/common/queries.py index c7fc472e..bd4d02aa 100644 --- a/rorapi/common/queries.py +++ b/rorapi/common/queries.py @@ -3,6 +3,8 @@ from titlecase import titlecase from collections import defaultdict +from elasticsearch.exceptions import RequestError + from rorapi.common.models import Errors from rorapi.common.matching import match_affiliation from rorapi.v2.models import ( @@ -270,7 +272,10 @@ def search_organizations(params): if error is not None: return error, None search = build_search_query(params) - return None, ListResultV2(search.execute()) + try: + return None, ListResultV2(search.execute()) + except RequestError as e: + return Errors([str(e)]), None def retrieve_organization(ror_id): diff --git a/rorapi/tests/tests_unit/tests_queries_v2.py b/rorapi/tests/tests_unit/tests_queries_v2.py index 013271e2..b1a30340 100644 --- a/rorapi/tests/tests_unit/tests_queries_v2.py +++ b/rorapi/tests/tests_unit/tests_queries_v2.py @@ -601,6 +601,22 @@ def test_malformed_search_organizations(self, search_mock): search_mock.assert_not_called() self.assertEqual(len(error.errors), 6) + @mock.patch('elasticsearch_dsl.Search.execute') + def test_search_organizations_request_error(self, search_mock): + from elasticsearch.exceptions import RequestError + + search_mock.side_effect = RequestError( + 400, + 'parsing_exception', + {'error': {'type': 'parsing_exception', 'reason': 'bad query'}}, + ) + + error, organizations = search_organizations({'query': 'query'}) + self.assertIsNone(organizations) + search_mock.assert_called_once() + self.assertEqual(len(error.errors), 1) + self.assertTrue('parsing_exception' in error.errors[0]) + class RetrieveOrganizationsTestCase(SimpleTestCase): From 14d67e958ce6339c456c84733bf664037f1b434d Mon Sep 17 00:00:00 2001 From: Joseph Rhoads Date: Wed, 23 Sep 2026 16:22:27 +0200 Subject: [PATCH 7/7] Lazy-load v2 schema with vendored GitHub fallback (#564) Co-authored-by: Cursor Agent --- rorapi/common/create_update.py | 19 +- rorapi/tests/tests_unit/tests_lazy_schema.py | 39 ++ rorapi/v2/ror_schema_v2_1.json | 371 +++++++++++++++++++ 3 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 rorapi/tests/tests_unit/tests_lazy_schema.py create mode 100644 rorapi/v2/ror_schema_v2_1.json diff --git a/rorapi/common/create_update.py b/rorapi/common/create_update.py index 8642e0bb..f965fba0 100644 --- a/rorapi/common/create_update.py +++ b/rorapi/common/create_update.py @@ -1,5 +1,9 @@ import copy +import functools +import json +import os from datetime import datetime + from rorapi.common.record_utils import * import update_address as ua from rorapi.v2.record_constants import * @@ -8,7 +12,16 @@ ) from rorapi.management.commands.generaterorid import check_ror_id -V2_SCHEMA = get_file_from_url("https://raw.githubusercontent.com/ror-community/ror-schema/refs/heads/master/ror_schema_v2_1.json") +VENDORED_SCHEMA_PATH = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "v2", "ror_schema_v2_1.json" +) + + +@functools.cache +def get_v2_schema(): + """Load the vendored v2.1 schema on first write; cache in-process.""" + with open(VENDORED_SCHEMA_PATH) as f: + return json.load(f) def update_record(json_input, existing_record): @@ -79,7 +92,7 @@ def new_record_from_json(json_input, version): new_ror_id = check_ror_id() print("new ror id: " + new_ror_id) new_record['id'] = new_ror_id - error, valid_data = validate_record(sort_list_fields(new_record), V2_SCHEMA) + error, valid_data = validate_record(sort_list_fields(new_record), get_v2_schema()) return error, valid_data @@ -92,5 +105,5 @@ def update_record_from_json(new_json, existing_org): error, updated_locations = update_locations(updated_record['locations']) if not error: updated_record['locations'] = updated_locations - error, valid_data = validate_record(sort_list_fields(updated_record), V2_SCHEMA) + error, valid_data = validate_record(sort_list_fields(updated_record), get_v2_schema()) return error, valid_data diff --git a/rorapi/tests/tests_unit/tests_lazy_schema.py b/rorapi/tests/tests_unit/tests_lazy_schema.py new file mode 100644 index 00000000..256c4e49 --- /dev/null +++ b/rorapi/tests/tests_unit/tests_lazy_schema.py @@ -0,0 +1,39 @@ +import importlib +import json +import os + +from django.test import SimpleTestCase + +from rorapi.common import create_update + + +class LazySchemaTests(SimpleTestCase): + def setUp(self): + create_update.get_v2_schema.cache_clear() + + def tearDown(self): + create_update.get_v2_schema.cache_clear() + + def test_import_does_not_load_schema(self): + importlib.reload(create_update) + self.assertEqual(create_update.get_v2_schema.cache_info().hits, 0) + self.assertEqual(create_update.get_v2_schema.cache_info().misses, 0) + + def test_loads_vendored_schema_on_first_use(self): + with open(create_update.VENDORED_SCHEMA_PATH) as f: + expected = json.load(f) + schema = create_update.get_v2_schema() + self.assertEqual(schema, expected) + self.assertEqual(schema["$id"], "http://ror.org/schemas/v2.0/organization") + # Second call uses the in-process cache (same object). + self.assertIs(create_update.get_v2_schema(), schema) + info = create_update.get_v2_schema.cache_info() + self.assertEqual(info.hits, 1) + self.assertEqual(info.misses, 1) + + def test_vendored_schema_file_exists(self): + self.assertTrue(os.path.isfile(create_update.VENDORED_SCHEMA_PATH)) + with open(create_update.VENDORED_SCHEMA_PATH) as f: + schema = json.load(f) + self.assertIn("required", schema) + self.assertIn("properties", schema) diff --git a/rorapi/v2/ror_schema_v2_1.json b/rorapi/v2/ror_schema_v2_1.json new file mode 100644 index 00000000..99506420 --- /dev/null +++ b/rorapi/v2/ror_schema_v2_1.json @@ -0,0 +1,371 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://ror.org/schemas/v2.0/organization", + "type": "object", + "additionalProperties": false, + "properties": { + "admin": { + "type": "object", + "additionalProperties": false, + "properties": { + "created": { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "schema_version": { + "type": "string", + "enum": [ + "1.0", + "2.0", + "2.1" + ] + } + }, + "required": [ + "date", + "schema_version" + ] + }, + "last_modified": { + "type": "object", + "additionalProperties": false, + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "schema_version": { + "type": "string", + "enum": [ + "1.0", + "2.0", + "2.1" + ] + } + }, + "required": [ + "date", + "schema_version" + ] + } + }, + "required": [ + "created", + "last_modified" + ] + }, + "domains": { + "type": "array", + "uniqueItems": true, + "default": [], + "items": { + "type": "string", + "pattern": "^((?=[a-z0-9-]{1,63}\\.)[a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,63}$" + } + }, + "established": { + "type": [ + "null", + "number" + ], + "default": null + }, + "external_ids": { + "type": "array", + "uniqueItems": true, + "default": [], + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "all": { + "type": "array", + "uniqueItems": true, + "default": [], + "items": { + "$ref": "#/$defs/non-empty-string" + } + }, + "type": { + "type": "string", + "enum": [ + "fundref", + "grid", + "isni", + "wikidata" + ] + }, + "preferred": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "all", + "type" + ] + } + }, + "id": { + "type": "string", + "default": "https://ror.org/012xzy7a9", + "pattern": "^https://ror.org/0[a-z|0-9]{8}$", + "readOnly": true + }, + "links": { + "type": "array", + "uniqueItems": true, + "default": [], + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "website", + "wikipedia" + ] + } + }, + "required": [ + "type", + "value" + ] + } + }, + "locations": { + "type": "array", + "uniqueItems": true, + "default": [], + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "geonames_id", + "geonames_details" + ], + "properties": { + "geonames_id": { + "type": "integer" + }, + "geonames_details": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "$ref": "#/$defs/non-empty-string" + }, + "lat": { + "type": [ + "number", + "null" + ] + }, + "lng": { + "type": [ + "number", + "null" + ] + }, + "continent_code": { + "type": [ + "string", + "null" + ], + "enum": [ + "AF", + "AN", + "AS", + "EU", + "NA", + "OC", + "SA", + null + ] + }, + "continent_name": { + "type": [ + "string", + "null" + ], + "enum": [ + "Africa", + "Antarctica", + "Asia", + "Europe", + "Oceania", + "South America", + "North America", + null + ] + }, + "country_code": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Z]{2}$", + "minLength": 2, + "maxLength": 2 + }, + "country_name": { + "type": [ + "string", + "null" + ] + }, + "country_subdivision_code": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Z0-9]{1,3}$", + "minLength": 1, + "maxLength": 3 + }, + "country_subdivision_name": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + }, + "names": { + "type": "array", + "uniqueItems": true, + "default": [], + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "$ref": "#/$defs/non-empty-string" + }, + "types": { + "type": "array", + "uniqueItems": true, + "default": [], + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "acronym", + "alias", + "label", + "ror_display" + ] + } + }, + "lang": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-z]{2}$", + "minLength": 2, + "maxLength": 2 + } + }, + "required": [ + "types", + "value" + ] + } + }, + "relationships": { + "type": "array", + "uniqueItems": true, + "default": [], + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "related", + "parent", + "child", + "successor", + "predecessor" + ] + }, + "id": { + "type": "string", + "pattern": "^https://ror.org/0[a-z|0-9]{8}$" + }, + "label": { + "$ref": "#/$defs/non-empty-string" + } + }, + "required": [ + "id", + "label", + "type" + ] + } + }, + "status": { + "type": "string", + "default": "active", + "enum": [ + "active", + "inactive", + "withdrawn" + ] + }, + "types": { + "type": "array", + "uniqueItems": true, + "minItems": 1, + "default": [], + "items": { + "type": "string", + "enum": [ + "education", + "funder", + "healthcare", + "company", + "archive", + "nonprofit", + "government", + "facility", + "other" + ] + } + } + }, + "$defs": { + "non-empty-string": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "admin", + "id", + "locations", + "names", + "status", + "types" + ] +} \ No newline at end of file