diff --git a/.claude/skills/private_hackathons.md b/.claude/skills/private_hackathons.md new file mode 100644 index 000000000..6546739e8 --- /dev/null +++ b/.claude/skills/private_hackathons.md @@ -0,0 +1,61 @@ +# Private Hackathon Whitelist (Quick Ops) + +Use `allowed_users` to make a leaderboard opt-in. + +## Semantics + +- `allowed_users = null` → leaderboard is open to everyone +- `allowed_users = []` → whitelist is enabled but empty (nobody can submit) +- `allowed_users = ["alice", "bob"]` → only those usernames can submit + +## Setup + +```bash +export API_URL="https://discord-cluster-manager-1f6c4782e60a.herokuapp.com" +export TOKEN="" +``` + +## Commands + +### View current whitelist + +```bash +curl -s "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +### Replace full whitelist + +```bash +curl -X PUT "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"usernames": ["alice", "bob", "charlie"]}' +``` + +### Append users + +```bash +curl -X POST "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"usernames": ["dave", "eve"]}' +``` + +### Remove users + +```bash +curl -X DELETE "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"usernames": ["eve"]}' +``` + +### Re-open leaderboard (disable whitelist) + +```bash +curl -X PUT "$API_URL/admin/leaderboards/helion-matmul/allowed-users" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"usernames": null}' +``` diff --git a/src/kernelbot/api/api_utils.py b/src/kernelbot/api/api_utils.py index ab1505ac9..89aaf237c 100644 --- a/src/kernelbot/api/api_utils.py +++ b/src/kernelbot/api/api_utils.py @@ -226,6 +226,14 @@ async def to_submit_info( try: with db_context as db: leaderboard_item = db.get_leaderboard(leaderboard_name) + + allowed = leaderboard_item.get("allowed_users") + if allowed is not None and user_name not in allowed: + raise HTTPException( + status_code=403, + detail="You are not authorized to submit to this leaderboard.", + ) + gpus = leaderboard_item.get("gpu_types", []) if gpu_type not in gpus: supported_gpus = ", ".join(gpus) if gpus else "None" diff --git a/src/kernelbot/api/main.py b/src/kernelbot/api/main.py index 960d11ffd..46ee8eeb9 100644 --- a/src/kernelbot/api/main.py +++ b/src/kernelbot/api/main.py @@ -675,6 +675,78 @@ async def admin_update_problems( } +@app.get("/admin/leaderboards/{leaderboard_name}/allowed-users") +async def get_allowed_users( + leaderboard_name: str, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + with db_context as db: + leaderboard = db.get_leaderboard(leaderboard_name) + return {"leaderboard": leaderboard_name, "allowed_users": leaderboard.get("allowed_users")} + + +def _normalize_usernames_payload(payload: dict) -> list[str]: + if "usernames" not in payload: + raise HTTPException(status_code=400, detail="Missing required field: usernames") + usernames = payload["usernames"] + if not isinstance(usernames, list): + raise HTTPException(status_code=400, detail="usernames must be a list of strings") + if not all(isinstance(u, str) for u in usernames): + raise HTTPException(status_code=400, detail="usernames must be a list of strings") + return sorted({u.strip() for u in usernames if u.strip()}) + + +@app.put("/admin/leaderboards/{leaderboard_name}/allowed-users") +async def set_allowed_users( + leaderboard_name: str, + payload: dict, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + if "usernames" not in payload: + raise HTTPException(status_code=400, detail="Missing required field: usernames") + + usernames = payload["usernames"] + if usernames is not None and not isinstance(usernames, list): + raise HTTPException(status_code=400, detail="usernames must be a list of strings or null") + if isinstance(usernames, list) and not all(isinstance(u, str) for u in usernames): + raise HTTPException(status_code=400, detail="usernames must be a list of strings or null") + + if isinstance(usernames, list): + usernames = sorted({u.strip() for u in usernames if u.strip()}) + + with db_context as db: + db.set_allowed_users(leaderboard_name, usernames) + return {"status": "ok", "leaderboard": leaderboard_name, "allowed_users": usernames} + + +@app.post("/admin/leaderboards/{leaderboard_name}/allowed-users") +async def append_allowed_users( + leaderboard_name: str, + payload: dict, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + usernames = _normalize_usernames_payload(payload) + with db_context as db: + updated = db.append_allowed_users(leaderboard_name, usernames) + return {"status": "ok", "leaderboard": leaderboard_name, "allowed_users": updated} + + +@app.delete("/admin/leaderboards/{leaderboard_name}/allowed-users") +async def delete_allowed_users( + leaderboard_name: str, + payload: dict, + _: Annotated[None, Depends(require_admin)], + db_context=Depends(get_db), +) -> dict: + usernames = _normalize_usernames_payload(payload) + with db_context as db: + updated = db.remove_allowed_users(leaderboard_name, usernames) + return {"status": "ok", "leaderboard": leaderboard_name, "allowed_users": updated} + + @app.get("/leaderboards") async def get_leaderboards(db_context=Depends(get_db)): """An endpoint that returns all leaderboards. diff --git a/src/kernelbot/cogs/leaderboard_cog.py b/src/kernelbot/cogs/leaderboard_cog.py index 457321f38..b913d1f8c 100644 --- a/src/kernelbot/cogs/leaderboard_cog.py +++ b/src/kernelbot/cogs/leaderboard_cog.py @@ -127,6 +127,22 @@ async def submit( ) req = prepare_submission(req, self.bot.backend) + # Check if leaderboard has an allowlist; if so, require a matching Discord role + with self.bot.leaderboard_db as db: + lb_item = db.get_leaderboard(req.leaderboard) + allowed_users = lb_item.get("allowed_users") + if allowed_users: + # Derive expected role name from leaderboard name prefix (e.g. "helion-foo" -> "helion") + role_name = req.leaderboard.split("-")[0] if "-" in req.leaderboard else req.leaderboard + member_roles = [r.name.lower() for r in interaction.user.roles] + if role_name.lower() not in member_roles: + await send_discord_message( + interaction, + f"You need the `{role_name}` role to submit to this leaderboard.", + ephemeral=True, + ) + return -1 + if req.gpus is None: view = await self.select_gpu_view(interaction, leaderboard_name, req.task_gpus) req.gpus = view.selected_gpus diff --git a/src/libkernelbot/db_types.py b/src/libkernelbot/db_types.py index 0a03ec524..d931d9a68 100644 --- a/src/libkernelbot/db_types.py +++ b/src/libkernelbot/db_types.py @@ -21,6 +21,7 @@ class LeaderboardItem(TypedDict): gpu_types: List[str] forum_id: int secret_seed: NotRequired[int] + allowed_users: NotRequired[Optional[List[str]]] class LeaderboardRankedEntry(TypedDict): diff --git a/src/libkernelbot/leaderboard_db.py b/src/libkernelbot/leaderboard_db.py index f76c00c9a..916052f83 100644 --- a/src/libkernelbot/leaderboard_db.py +++ b/src/libkernelbot/leaderboard_db.py @@ -588,7 +588,7 @@ def get_leaderboard_templates(self, leaderboard_name: str) -> Dict[str, str]: def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem": self.cursor.execute( """ - SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description + SELECT id, name, deadline, task, creator_id, forum_id, secret_seed, description, allowed_users FROM leaderboard.leaderboard WHERE name = %s """, @@ -599,7 +599,7 @@ def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem": if res: task = LeaderboardTask.from_dict(res[3]) - return LeaderboardItem( + item = LeaderboardItem( id=res[0], name=res[1], deadline=res[2], @@ -610,9 +610,76 @@ def get_leaderboard(self, leaderboard_name: str) -> "LeaderboardItem": gpu_types=self.get_leaderboard_gpu_types(res[1]), description=res[7], ) + if res[8] is not None: + item["allowed_users"] = list(res[8]) + return item else: raise LeaderboardDoesNotExist(leaderboard_name) + def set_allowed_users(self, leaderboard_name: str, usernames: Optional[List[str]]) -> None: + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard + SET allowed_users = %s + WHERE name = %s + """, + (usernames, leaderboard_name), + ) + if self.cursor.rowcount == 0: + raise LeaderboardDoesNotExist(leaderboard_name) + self.connection.commit() + + def append_allowed_users(self, leaderboard_name: str, usernames: List[str]) -> List[str]: + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard + SET allowed_users = CASE + WHEN allowed_users IS NULL THEN %s + ELSE ( + SELECT ARRAY( + SELECT DISTINCT elem + FROM unnest(allowed_users || %s::text[]) AS elem + ORDER BY elem + ) + ) + END + WHERE name = %s + RETURNING allowed_users + """, + (usernames, usernames, leaderboard_name), + ) + res = self.cursor.fetchone() + if res is None: + raise LeaderboardDoesNotExist(leaderboard_name) + self.connection.commit() + return list(res[0]) if res[0] is not None else [] + + def remove_allowed_users(self, leaderboard_name: str, usernames: List[str]) -> List[str]: + self.cursor.execute( + """ + UPDATE leaderboard.leaderboard + SET allowed_users = CASE + WHEN allowed_users IS NULL THEN NULL + ELSE ( + SELECT ARRAY( + SELECT elem + FROM unnest(allowed_users) AS elem + WHERE NOT (elem = ANY(%s::text[])) + ORDER BY elem + ) + ) + END + WHERE name = %s + RETURNING allowed_users + """, + (usernames, leaderboard_name), + ) + res = self.cursor.fetchone() + if res is None: + raise LeaderboardDoesNotExist(leaderboard_name) + self.connection.commit() + return list(res[0]) if res[0] is not None else None + def get_leaderboard_submissions( self, leaderboard_name: str, diff --git a/src/migrations/20260303_01_add-allowed-users.py b/src/migrations/20260303_01_add-allowed-users.py new file mode 100644 index 000000000..7405af267 --- /dev/null +++ b/src/migrations/20260303_01_add-allowed-users.py @@ -0,0 +1,21 @@ +""" +add-allowed-users +""" + +from yoyo import step + +__depends__ = {"20260226_01_WgYAV-queryindex"} + + +steps = [ + step( + """ + ALTER TABLE leaderboard.leaderboard + ADD COLUMN allowed_users TEXT[] DEFAULT NULL; + """, + """ + ALTER TABLE leaderboard.leaderboard + DROP COLUMN IF EXISTS allowed_users; + """, + ), +] diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index fa4b6751f..f91409474 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -364,6 +364,97 @@ def test_update_problems_with_force(self, test_client, mock_backend): call_kwargs = mock_sync.call_args[1] assert call_kwargs["force"] is True + +class TestAdminAllowedUsers: + """Test admin allowed-users endpoints.""" + + def test_get_allowed_users(self, test_client, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.get_leaderboard = MagicMock( + return_value={"name": "test-leaderboard", "allowed_users": ["alice"]} + ) + + response = test_client.get( + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + ) + assert response.status_code == 200 + assert response.json() == { + "leaderboard": "test-leaderboard", + "allowed_users": ["alice"], + } + + def test_set_allowed_users(self, test_client, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.set_allowed_users = MagicMock() + + response = test_client.put( + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + json={"usernames": ["bob", "alice", "bob"]}, + ) + assert response.status_code == 200 + mock_backend.db.set_allowed_users.assert_called_once_with( + "test-leaderboard", ["alice", "bob"] + ) + + def test_append_allowed_users(self, test_client, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.append_allowed_users = MagicMock(return_value=["alice", "bob"]) + + response = test_client.post( + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + json={"usernames": ["bob", "alice"]}, + ) + assert response.status_code == 200 + assert response.json()["allowed_users"] == ["alice", "bob"] + mock_backend.db.append_allowed_users.assert_called_once_with( + "test-leaderboard", ["alice", "bob"] + ) + + def test_delete_allowed_users(self, test_client, mock_backend): + mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) + mock_backend.db.__exit__ = MagicMock(return_value=None) + mock_backend.db.remove_allowed_users = MagicMock(return_value=["bob"]) + + response = test_client.request( + "DELETE", + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + json={"usernames": ["alice"]}, + ) + assert response.status_code == 200 + assert response.json()["allowed_users"] == ["bob"] + mock_backend.db.remove_allowed_users.assert_called_once_with( + "test-leaderboard", ["alice"] + ) + + def test_allowed_users_validation_errors(self, test_client, mock_backend): + response = test_client.post( + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + json={}, + ) + assert response.status_code == 400 + + response = test_client.post( + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + json={"usernames": "alice"}, + ) + assert response.status_code == 400 + + response = test_client.post( + "/admin/leaderboards/test-leaderboard/allowed-users", + headers={"Authorization": "Bearer test_token"}, + json={"usernames": ["alice", 123]}, + ) + assert response.status_code == 400 + def test_update_problems_with_custom_repo_and_branch(self, test_client, mock_backend): """POST /admin/update-problems with custom repository and branch.""" mock_backend.db.__enter__ = MagicMock(return_value=mock_backend.db) diff --git a/tests/test_leaderboard_db.py b/tests/test_leaderboard_db.py index a9680ad8f..0be70509a 100644 --- a/tests/test_leaderboard_db.py +++ b/tests/test_leaderboard_db.py @@ -798,3 +798,37 @@ def test_get_user_submissions_with_multiple_runs(database, submit_leaderboard): assert 1.5 in scores assert 2.0 in scores + +def test_allowed_users_set_append_remove(database, submit_leaderboard): + with database as db: + # Default is open (NULL) + lb = db.get_leaderboard("submit-leaderboard") + assert lb.get("allowed_users") is None + + # Set explicit whitelist + db.set_allowed_users("submit-leaderboard", ["alice"]) + lb = db.get_leaderboard("submit-leaderboard") + assert lb["allowed_users"] == ["alice"] + + # Append deduplicates and preserves sorted output + updated = db.append_allowed_users("submit-leaderboard", ["bob", "alice"]) + assert updated == ["alice", "bob"] + lb = db.get_leaderboard("submit-leaderboard") + assert lb["allowed_users"] == ["alice", "bob"] + + # Remove a user + updated = db.remove_allowed_users("submit-leaderboard", ["alice"]) + assert updated == ["bob"] + lb = db.get_leaderboard("submit-leaderboard") + assert lb["allowed_users"] == ["bob"] + + # Removing all users leaves an explicit empty whitelist (locked down) + updated = db.remove_allowed_users("submit-leaderboard", ["bob"]) + assert updated == [] + lb = db.get_leaderboard("submit-leaderboard") + assert lb["allowed_users"] == [] + + # Setting NULL re-opens the leaderboard + db.set_allowed_users("submit-leaderboard", None) + lb = db.get_leaderboard("submit-leaderboard") + assert lb.get("allowed_users") is None