diff --git a/README.md b/README.md index dfd0f8c..ddf4353 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ Compose reads `AGENTDNA_API_KEY` / `AGENTDNA_CHAIN_URL` / `JWT_SECRET` etc. from ## Endpoints -All endpoints are under `/agent-admin/v1`. Several share a common response envelope, `AgentResponse`: +Most endpoints are under `/agent-admin/v1`; the dashboard password endpoint is +under `/dashboard/v1`. Several share a common response envelope, `AgentResponse`: - `status` (bool): whether the operation succeeded - `message` (string): human-readable result @@ -142,6 +143,35 @@ Sample failure: } ``` +### POST `/dashboard/v1/admin-change-password` + +Changes an admin's password after verifying the current password. Unknown +usernames and incorrect current passwords return the same response so the +endpoint does not reveal whether an account exists. + +Request type: `application/json` + +```json +{ + "username": "admin-user", + "currentPassword": "current-password", + "newPassword": "new-password" +} +``` + +The new password must contain at least 8 characters and must differ from the +current password. Success returns HTTP 200: + +```json +{ + "status": true, + "message": "Password updated successfully." +} +``` + +Credential failures return HTTP 401 with `Incorrect credentials`. Password +policy failures return HTTP 400 with the applicable message. + ### POST `/create-agent` Creates a new agent: stores its policy file (UUID-suffixed), deploys the agent NFT via AgentDNA, and records the agent in the `agents` table. The caller's DID (`creator_did`) must already be registered via `/register-admin`. diff --git a/db.py b/db.py index 6b1e746..25c4947 100644 --- a/db.py +++ b/db.py @@ -115,6 +115,25 @@ def get_admin_by_username(username: str) -> dict[str, str] | None: return None return {"did": row[0], "org": row[1], "password": row[2]} + +def update_admin_password( + username: str, + current_password_hash: str, + new_password_hash: str, +) -> bool: + """Replace the expected password hash, returning whether a row was updated.""" + with pool.connection() as conn: + cur = conn.execute( + """ + UPDATE admin + SET password = %s + WHERE username = %s AND password = %s + """, + (new_password_hash, username, current_password_hash), + ) + return cur.rowcount > 0 + + def get_all_agents() -> list[dict[str, str]]: """Return all registered agents (without policy — see get_agent_by_did for that).""" with pool.connection() as conn: @@ -170,4 +189,4 @@ def agent_exists(did: str) -> bool: "SELECT 1 FROM agents WHERE did = %s", (did,), ).fetchone() - return row is not None \ No newline at end of file + return row is not None diff --git a/main.py b/main.py index 0fdc342..fe75a4b 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,8 @@ CreateAgentResponse, RegisterAdminRequest, LoginRequest, + AdminChangePasswordRequest, + AdminChangePasswordResponse, AppRequest ) from services import ( @@ -26,6 +28,7 @@ list_agents, get_agent, login, + change_admin_password, ) from agentdna.types import ( IntentWorkflow @@ -169,6 +172,29 @@ async def login_endpoint(payload: LoginRequest) -> AgentResponse: return AgentResponse(status=status, message=message, data=token) +@app.post( + "/dashboard/v1/admin-change-password", + response_model=AdminChangePasswordResponse, + responses={ + 400: {"model": AdminChangePasswordResponse}, + 401: {"model": AdminChangePasswordResponse}, + 500: {"model": AdminChangePasswordResponse}, + }, +) +async def admin_change_password_endpoint( + payload: AdminChangePasswordRequest, +) -> JSONResponse: + status, message, status_code = await change_admin_password( + payload.username, + payload.current_password, + payload.new_password, + ) + return JSONResponse( + status_code=status_code, + content={"status": status, "message": message}, + ) + + @app.post("/agent-admin/v1/update-agent-policies", response_model=AgentResponse) async def update_agent_policies_endpoint( policy: UploadFile = File(...), diff --git a/schemas.py b/schemas.py index 41399dc..61d9094 100644 --- a/schemas.py +++ b/schemas.py @@ -18,6 +18,25 @@ class LoginRequest(BaseModel): password: str = Field(..., description="Admin password") +class AdminChangePasswordRequest(BaseModel): + username: str = Field(..., description="Admin username") + current_password: str = Field( + ..., + alias="currentPassword", + description="Admin's current password", + ) + new_password: str = Field( + ..., + alias="newPassword", + description="Admin's new password", + ) + + +class AdminChangePasswordResponse(BaseModel): + status: bool = Field(..., description="Whether the password was updated") + message: str = Field(..., description="Human-readable result message") + + class CreateAgentResponse(BaseModel): status: bool = Field(..., description="Whether the operation succeeded") message: str = Field(..., description="Human-readable result message") diff --git a/services.py b/services.py index 65d13d8..888e75e 100644 --- a/services.py +++ b/services.py @@ -1,3 +1,4 @@ +import logging import shutil from pathlib import Path from uuid import uuid4 @@ -18,6 +19,7 @@ add_registered_agent, get_username_by_did, get_admin_by_username, + update_admin_password, get_all_agents, get_agent_by_did, set_agent_policy, @@ -27,7 +29,10 @@ from security import hash_password, verify_password, create_access_token from config import settings +logger = logging.getLogger(__name__) + POLICY_STORE = Path(__file__).parent / "policy_store" +MIN_PASSWORD_LENGTH = 8 def _agent_dir(org_id: str, agent_name: str) -> Path: @@ -165,6 +170,46 @@ async def login(username: str, password: str) -> tuple[bool, str, str | None]: return True, "Login successful", token +async def change_admin_password( + username: str, + current_password: str, + new_password: str, +) -> tuple[bool, str, int]: + try: + admin = get_admin_by_username(username) + except Exception: + logger.exception("Failed to load admin credentials for password change") + return False, "Unable to update password.", 500 + + if admin is None or not verify_password(current_password, admin["password"]): + return False, "Incorrect credentials", 401 + + if verify_password(new_password, admin["password"]): + return False, "New password must differ from the current one.", 400 + + if len(new_password) < MIN_PASSWORD_LENGTH: + return ( + False, + f"Password must be at least {MIN_PASSWORD_LENGTH} characters.", + 400, + ) + + try: + updated = update_admin_password( + username, + admin["password"], + hash_password(new_password), + ) + except Exception: + logger.exception("Failed to persist admin password change") + return False, "Unable to update password.", 500 + + if not updated: + return False, "Incorrect credentials", 401 + + return True, "Password updated successfully.", 200 + + async def authorize_action(agent_id: str, action_intent: str, intent_workflow: IntentWorkflow) -> tuple[bool, str]: provenance_layer = Provenance( name="admin-server", @@ -303,4 +348,4 @@ async def get_agent(did: str) -> tuple[bool, str, dict[str, str] | None]: return False, f"Failed to fetch agent: {exc}", None if agent is None: return False, f"No agent found with did '{did}'", None - return True, "Agent retrieved", agent \ No newline at end of file + return True, "Agent retrieved", agent diff --git a/tests/test_admin_change_password.py b/tests/test_admin_change_password.py new file mode 100644 index 0000000..eda7f06 --- /dev/null +++ b/tests/test_admin_change_password.py @@ -0,0 +1,181 @@ +import unittest +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + +from main import app +from services import change_admin_password + + +class ChangeAdminPasswordServiceTests(unittest.IsolatedAsyncioTestCase): + async def test_unknown_username_returns_generic_credentials_error(self): + with patch("services.get_admin_by_username", return_value=None): + result = await change_admin_password( + "unknown", + "old-password", + "new-password", + ) + + self.assertEqual(result, (False, "Incorrect credentials", 401)) + + async def test_wrong_current_password_returns_generic_credentials_error(self): + admin = {"password": "stored-hash"} + with ( + patch("services.get_admin_by_username", return_value=admin), + patch("services.verify_password", return_value=False), + ): + result = await change_admin_password("admin", "wrong-password", "new-password") + + self.assertEqual(result, (False, "Incorrect credentials", 401)) + + async def test_new_password_must_differ_from_current_password(self): + admin = {"password": "stored-hash"} + with ( + patch("services.get_admin_by_username", return_value=admin), + patch("services.verify_password", side_effect=[True, True]), + ): + result = await change_admin_password("admin", "same-password", "same-password") + + self.assertEqual( + result, + (False, "New password must differ from the current one.", 400), + ) + + async def test_new_password_must_be_at_least_eight_characters(self): + admin = {"password": "stored-hash"} + with ( + patch("services.get_admin_by_username", return_value=admin), + patch("services.verify_password", side_effect=[True, False]), + ): + result = await change_admin_password("admin", "old-password", "short") + + self.assertEqual( + result, + (False, "Password must be at least 8 characters.", 400), + ) + + async def test_success_hashes_and_persists_the_new_password(self): + admin = {"password": "stored-hash"} + with ( + patch("services.get_admin_by_username", return_value=admin), + patch("services.verify_password", side_effect=[True, False]), + patch("services.hash_password", return_value="new-hash") as hash_password, + patch("services.update_admin_password", return_value=True) as update_password, + ): + result = await change_admin_password( + "admin", + "old-password", + "new-password", + ) + + self.assertEqual(result, (True, "Password updated successfully.", 200)) + hash_password.assert_called_once_with("new-password") + update_password.assert_called_once_with("admin", "stored-hash", "new-hash") + + async def test_database_read_error_returns_safe_server_error(self): + with ( + patch( + "services.get_admin_by_username", + side_effect=RuntimeError("db unavailable"), + ), + patch("services.logger.exception") as log_exception, + ): + result = await change_admin_password("admin", "old-password", "new-password") + + self.assertEqual(result, (False, "Unable to update password.", 500)) + log_exception.assert_called_once_with( + "Failed to load admin credentials for password change" + ) + + async def test_database_write_error_returns_safe_server_error(self): + admin = {"password": "stored-hash"} + with ( + patch("services.get_admin_by_username", return_value=admin), + patch("services.verify_password", side_effect=[True, False]), + patch("services.hash_password", return_value="new-hash"), + patch( + "services.update_admin_password", + side_effect=RuntimeError("db unavailable"), + ), + patch("services.logger.exception") as log_exception, + ): + result = await change_admin_password("admin", "old-password", "new-password") + + self.assertEqual(result, (False, "Unable to update password.", 500)) + log_exception.assert_called_once_with( + "Failed to persist admin password change" + ) + + +class ChangeAdminPasswordEndpointTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls): + cls.client.close() + + def test_endpoint_returns_service_status_and_exact_response_shape(self): + payload = { + "username": "admin", + "currentPassword": "old-password", + "newPassword": "new-password", + } + cases = [ + ((True, "Password updated successfully.", 200), 200), + ((False, "Incorrect credentials", 401), 401), + ((False, "Password must be at least 8 characters.", 400), 400), + ] + + for service_result, expected_status_code in cases: + with self.subTest(status_code=expected_status_code): + change_password = AsyncMock(return_value=service_result) + with patch("main.change_admin_password", new=change_password): + response = self.client.post( + "/dashboard/v1/admin-change-password", + json=payload, + ) + + self.assertEqual(response.status_code, expected_status_code) + self.assertEqual( + response.json(), + {"status": service_result[0], "message": service_result[1]}, + ) + change_password.assert_awaited_once_with( + "admin", + "old-password", + "new-password", + ) + + def test_all_request_fields_are_required(self): + with patch("main.change_admin_password", new=AsyncMock()) as change_password: + response = self.client.post( + "/dashboard/v1/admin-change-password", + json={ + "username": "admin", + "currentPassword": "old-password", + }, + ) + + self.assertEqual(response.status_code, 422) + change_password.assert_not_awaited() + + def test_openapi_contract_uses_camel_case_request_fields(self): + document = app.openapi() + operation = document["paths"]["/dashboard/v1/admin-change-password"]["post"] + request_schema = operation["requestBody"]["content"]["application/json"][ + "schema" + ] + schema_name = request_schema["$ref"].rsplit("/", 1)[-1] + properties = document["components"]["schemas"][schema_name]["properties"] + + self.assertEqual( + set(properties), + {"username", "currentPassword", "newPassword"}, + ) + self.assertTrue({"200", "400", "401"}.issubset(operation["responses"])) + + +if __name__ == "__main__": + unittest.main()