diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index c3cc961..20a259e 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -225,7 +225,7 @@ async function moveUserToRole(org, user, targetRoleDisplayName) { if (user.role === targetRoleDisplayName) return try { await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, { - method: 'PUT', + method: 'PATCH', body: { role: targetRoleDisplayName } }) await loadOrgs() @@ -272,19 +272,17 @@ function deleteRole(role) { } async function toggleRolePermission(role, pid, checked) { - // Calculate new permissions array + // Optimistic update + const prevPermissions = [...role.permissions] const newPermissions = checked ? [...role.permissions, pid] : role.permissions.filter(p => p !== pid) - - // Optimistic update - const prevPermissions = [...role.permissions] role.permissions = newPermissions try { - await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { - method: 'PUT', - body: { display_name: role.display_name, permissions: newPermissions } + const method = checked ? 'POST' : 'DELETE' + await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}/permissions/${pid}`, { + method }) await loadOrgs() } catch (e) { @@ -564,7 +562,7 @@ async function submitDialog() { // Close dialog immediately, then perform async operation closeDialog() - apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } }) + apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500) loadOrgs() @@ -592,7 +590,7 @@ async function submitDialog() { // Close dialog immediately, then perform async operation closeDialog() - apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } }) + apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500) loadOrgs() @@ -620,7 +618,7 @@ async function submitDialog() { // Close dialog immediately, then perform async operation closeDialog() - apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } }) + apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) onUserNameSaved() @@ -649,7 +647,7 @@ async function submitDialog() { // Display name or domain changed const params = new URLSearchParams({ permission_id: permission.scope, display_name: newDisplay }) if (newDomain) params.set('domain', newDomain) - apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' }) + apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) } else { // No changes return diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index 393d614..df42abb 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -336,7 +336,7 @@ const saveName = async () => { if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return } try { saving.value = true - await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } }) + await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } }) showNameDialog.value = false await authStore.loadUserInfo() authStore.showMessage('Name updated successfully!', 'success', 3000) diff --git a/paskia/db/json.py b/paskia/db/json.py index b5343fc..31c5e56 100644 --- a/paskia/db/json.py +++ b/paskia/db/json.py @@ -468,14 +468,32 @@ class DB: visits=u.visits, ) - def _build_role(self, role_uuid: str) -> Role: - """Build a Role object from internal storage. Caller must hold lock.""" + def _build_role(self, role_uuid: str, org_filter: bool = False) -> Role: + """Build a Role object from internal storage. Caller must hold lock. + + Args: + role_uuid: The role UUID string + org_filter: If True, filter permissions to only those the org can grant + """ r = self._data.roles[role_uuid] + permissions = list(r.permissions) + + # Filter by org if requested + if org_filter: + org_uuid = r.org + if org_uuid in self._data.orgs: + org_allowed_scopes = { + p.scope + for pid, p in self._data.permissions.items() + if org_uuid in p.orgs + } + permissions = [p for p in permissions if p in org_allowed_scopes] + return Role( uuid=UUID(role_uuid), org_uuid=UUID(r.org), display_name=r.display_name, - permissions=list(r.permissions), + permissions=permissions, ) def _build_org(self, org_uuid: str, include_roles: bool = False) -> Org: @@ -491,8 +509,9 @@ class DB: permissions=perm_scopes, ) if include_roles: + # When building roles for org display, filter by what org can grant org.roles = [ - self._build_role(role_uuid) + self._build_role(role_uuid, org_filter=True) for role_uuid, r in self._data.roles.items() if r.org == org_uuid ] @@ -582,6 +601,16 @@ class DB: {p: True for p in role.permissions} if role.permissions else {} ) + def update_role_name( + self, role_uuid: UUID, display_name: str, actor: str = "system" + ) -> None: + """Update only the role display name (intent-based API).""" + with self.session(actor): + key = str(role_uuid) + if key not in self._data.roles: + raise ValueError("Role not found") + self._data.roles[key].display_name = display_name + def delete_role(self, role_uuid: UUID, actor: str = "system") -> None: with self.session(actor): key = str(role_uuid) @@ -599,6 +628,37 @@ class DB: raise ValueError("Role not found") return self._build_role(key) + def get_role_hidden_permissions(self, role_uuid: UUID) -> list[str]: + """Get permission scopes assigned to role but not grantable by its org. + + These are "hidden" permissions that should be preserved when updating + the role, so they can become effective again if the org regains access. + """ + with self._lock: + key = str(role_uuid) + if key not in self._data.roles: + return [] + + role_data = self._data.roles[key] + org_uuid = role_data.org + + # Get org's grantable scopes + if org_uuid not in self._data.orgs: + return [] + + org_allowed_scopes = { + p.scope + for pid, p in self._data.permissions.items() + if org_uuid in p.orgs + } + + # Return scopes in role but not in org + return [ + scope + for scope in role_data.permissions + if scope not in org_allowed_scopes + ] + # ------------------------------------------------------------------------- # Credential operations # ------------------------------------------------------------------------- @@ -844,6 +904,16 @@ class DB: p.orgs[key] = True break + def update_organization_name( + self, org_uuid: UUID, display_name: str, actor: str = "system" + ) -> None: + """Update only the organization display name (intent-based API).""" + with self.session(actor): + key = str(org_uuid) + if key not in self._data.orgs: + raise ValueError("Organization not found") + self._data.orgs[key].display_name = display_name + def delete_organization(self, org_uuid: UUID, actor: str = "system") -> None: with self.session(actor): key = str(org_uuid) @@ -1189,18 +1259,43 @@ class DB: elif permission_id in self._data.roles[key].permissions: del self._data.roles[key].permissions[permission_id] - def get_role_permissions(self, role_uuid: UUID) -> list[Permission]: + def get_role_permissions( + self, role_uuid: UUID, filter_by_org: bool = True + ) -> list[Permission]: """Get permissions granted by a role. Note: Roles store scopes, so we need to look up permissions by scope. + + Args: + role_uuid: The role UUID + filter_by_org: If True, only return permissions that the role's org + can grant. Set to False to see all assigned permissions + regardless of org restrictions. """ with self._lock: key = str(role_uuid) if key not in self._data.roles: return [] - scopes = list(self._data.roles[key].permissions.keys()) + role_data = self._data.roles[key] + scopes = list(role_data.permissions.keys()) + + # Get org permissions if filtering + org_allowed_scopes = None + if filter_by_org: + org_uuid = role_data.org + if org_uuid in self._data.orgs: + org_allowed_scopes = { + p.scope + for pid, p in self._data.permissions.items() + if org_uuid in p.orgs + } + permissions = [] for scope in scopes: + # Skip if org filtering is enabled and scope not allowed by org + if org_allowed_scopes is not None and scope not in org_allowed_scopes: + continue + # Find permission with this scope for pid, p in self._data.permissions.items(): if p.scope == scope: diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 336497c..3bb8f2d 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -25,7 +25,12 @@ app = FastAPI() def is_global_admin(ctx) -> bool: """Check if user has global admin permission.""" - return "auth:admin" in ctx.role.permissions + effective_scopes = ( + {p.scope for p in (ctx.permissions or [])} + if ctx.permissions + else set(ctx.role.permissions or []) + ) + return "auth:admin" in effective_scopes def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool: @@ -34,7 +39,12 @@ def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool: If org_uuid is provided, checks if user is admin of that specific org. If org_uuid is None, checks if user is admin of their own org. """ - if "auth:org:admin" not in ctx.role.permissions: + effective_scopes = ( + {p.scope for p in (ctx.permissions or [])} + if ctx.permissions + else set(ctx.role.permissions or []) + ) + if "auth:org:admin" not in effective_scopes: return False if org_uuid is None: return True @@ -148,13 +158,14 @@ async def admin_create_org( return {"uuid": str(org_uuid)} -@app.put("/orgs/{org_uuid}") -async def admin_update_org( +@app.patch("/orgs/{org_uuid}") +async def admin_update_org_name( org_uuid: UUID, request: Request, payload: dict = Body(...), auth=AUTH_COOKIE, ): + """Update organization display name only.""" ctx = await authz.verify( auth, ["auth:admin", "auth:org:admin"], @@ -165,26 +176,11 @@ async def admin_update_org( raise authz.AuthException( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - from ..db import Org as OrgDC # local import to avoid cycles + display_name = payload.get("display_name") + if not display_name: + raise ValueError("display_name is required") - current = db.get_organization(str(org_uuid)) - display_name = payload.get("display_name") or current.display_name - permissions = payload.get("permissions") - if permissions is None: - permissions = current.permissions or [] - - # Sanity check: prevent removing permissions that would break current user's admin access - org_admin_perm = "auth:org:admin" - - # If current user is org admin (not global admin), ensure org admin perm remains - if not is_global_admin(ctx) and is_org_admin(ctx, org_uuid): - if org_admin_perm not in permissions: - raise ValueError( - "Cannot remove organization admin permission from your own organization" - ) - - org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) - db.update_organization(org) + db.update_organization_name(org_uuid, display_name) return {"status": "ok"} @@ -301,15 +297,15 @@ async def admin_create_role( return {"uuid": str(role_uuid)} -@app.put("/orgs/{org_uuid}/roles/{role_uuid}") -async def admin_update_role( +@app.patch("/orgs/{org_uuid}/roles/{role_uuid}") +async def admin_update_role_name( org_uuid: UUID, role_uuid: UUID, request: Request, payload: dict = Body(...), auth=AUTH_COOKIE, ): - # Verify caller is global admin or admin of provided org + """Update role display name only.""" ctx = await authz.verify( auth, ["auth:admin", "auth:org:admin"], @@ -323,35 +319,85 @@ async def admin_update_role( role = db.get_role(role_uuid) if role.org_uuid != org_uuid: raise HTTPException(status_code=404, detail="Role not found in organization") - from ..db import Role as RoleDC - display_name = payload.get("display_name") or role.display_name - permissions = payload.get("permissions") - if permissions is None: - permissions = role.permissions - org = db.get_organization(str(org_uuid)) - grantable = set(org.permissions or []) - existing_permissions = set(role.permissions) - for pid in permissions: - db.get_permission(pid) - if pid not in existing_permissions and pid not in grantable: - raise ValueError(f"Permission not grantable by org: {pid}") + display_name = payload.get("display_name") + if not display_name: + raise ValueError("display_name is required") - # Sanity check: prevent admin from removing their own access via role update - if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid: - has_admin_access = ( - "auth:admin" in permissions or "auth:org:admin" in permissions - ) - if not has_admin_access: - raise ValueError("Cannot update your own role to remove admin permissions") + db.update_role_name(role_uuid, display_name) + return {"status": "ok"} - updated = RoleDC( - uuid=role_uuid, - org_uuid=org_uuid, - display_name=display_name, - permissions=permissions, + +@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_id}") +async def admin_add_role_permission( + org_uuid: UUID, + role_uuid: UUID, + permission_id: str, + request: Request, + auth=AUTH_COOKIE, +): + """Add a permission to a role (intent-based API).""" + ctx = await authz.verify( + auth, + ["auth:admin", "auth:org:admin"], + match=permutil.has_any, + host=request.headers.get("host"), ) - db.update_role(updated) + if not can_manage_org(ctx, org_uuid): + raise authz.AuthException( + status_code=403, detail="Insufficient permissions", mode="forbidden" + ) + + role = db.get_role(role_uuid) + if role.org_uuid != org_uuid: + raise HTTPException(status_code=404, detail="Role not found in organization") + + # Verify permission exists and org can grant it + db.get_permission(permission_id) + org = db.get_organization(str(org_uuid)) + if permission_id not in org.permissions: + raise ValueError(f"Permission not grantable by organization") + + db.add_permission_to_role(role_uuid, permission_id) + return {"status": "ok"} + + +@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_id}") +async def admin_remove_role_permission( + org_uuid: UUID, + role_uuid: UUID, + permission_id: str, + request: Request, + auth=AUTH_COOKIE, +): + """Remove a permission from a role (intent-based API).""" + ctx = await authz.verify( + auth, + ["auth:admin", "auth:org:admin"], + match=permutil.has_any, + host=request.headers.get("host"), + ) + if not can_manage_org(ctx, org_uuid): + raise authz.AuthException( + status_code=403, detail="Insufficient permissions", mode="forbidden" + ) + + role = db.get_role(role_uuid) + if role.org_uuid != org_uuid: + raise HTTPException(status_code=404, detail="Role not found in organization") + + # Sanity check: prevent admin from removing their own access + if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid: + if permission_id in ["auth:admin", "auth:org:admin"]: + # Check if removing this permission would leave no admin access + remaining_perms = set(role.permissions) - {permission_id} + if ( + "auth:admin" not in remaining_perms + and "auth:org:admin" not in remaining_perms + ): + raise ValueError("Cannot remove your own admin permissions") + + db.remove_permission_from_role(role_uuid, permission_id) return {"status": "ok"} @@ -427,7 +473,7 @@ async def admin_create_user( return {"uuid": str(user_uuid)} -@app.put("/orgs/{org_uuid}/users/{user_uuid}/role") +@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role") async def admin_update_user_role( org_uuid: UUID, user_uuid: UUID, @@ -668,7 +714,7 @@ async def admin_get_user_detail( } -@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name") +@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name") async def admin_update_user_display_name( org_uuid: UUID, user_uuid: UUID, @@ -923,7 +969,7 @@ async def admin_create_permission( return {"status": "ok"} -@app.put("/permission") +@app.patch("/permission") async def admin_update_permission( request: Request, auth=AUTH_COOKIE, diff --git a/paskia/fastapi/authz.py b/paskia/fastapi/authz.py index 3be5dd3..ce2426c 100644 --- a/paskia/fastapi/authz.py +++ b/paskia/fastapi/authz.py @@ -94,14 +94,19 @@ async def verify( if not match(ctx, perm): # Determine which permissions are missing for clearer diagnostics - missing = sorted(set(perm) - set(ctx.role.permissions)) + effective_scopes = ( + {p.scope for p in (ctx.permissions or [])} + if ctx.permissions + else set(ctx.role.permissions or []) + ) + missing = sorted(set(perm) - effective_scopes) logger.warning( "Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501 getattr(ctx.user, "uuid", "?"), getattr(ctx.role, "display_name", "?"), missing, perm, - ctx.role.permissions, + list(effective_scopes), ) raise AuthException( status_code=403, mode="forbidden", detail="Permission required" diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 408b159..93cd347 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -33,7 +33,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException): ) -@app.put("/display-name") +@app.patch("/display-name") async def user_update_display_name( request: Request, response: Response, diff --git a/paskia/util/permutil.py b/paskia/util/permutil.py index 98144a2..d4f883f 100644 --- a/paskia/util/permutil.py +++ b/paskia/util/permutil.py @@ -17,12 +17,24 @@ def _match(perms: set[str], patterns: Sequence[str]): ) +def _get_effective_scopes(ctx) -> set[str]: + """Get effective permission scopes from context. + + Returns scopes from ctx.permissions (filtered by org) if available, + otherwise falls back to ctx.role.permissions for backwards compatibility. + """ + if ctx.permissions: + return {p.scope for p in ctx.permissions} + # Fallback for contexts without effective permissions computed + return set(ctx.role.permissions or []) + + def has_any(ctx, patterns: Sequence[str]) -> bool: - return any(_match(ctx.role.permissions, patterns)) if ctx else False + return any(_match(_get_effective_scopes(ctx), patterns)) if ctx else False def has_all(ctx, patterns: Sequence[str]) -> bool: - return all(_match(ctx.role.permissions, patterns)) if ctx else False + return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False async def session_context(auth: str | None, host: str | None = None): diff --git a/tests/test_admin.py b/tests/test_admin.py index 13ec40e..af90536 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -338,7 +338,7 @@ class TestAdminOrganizations: self, client: httpx.AsyncClient, session_token: str, test_org ): """Admin should be able to update an organization.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}", json={"display_name": "Updated Org Name"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -355,11 +355,10 @@ class TestAdminOrganizations: test_org, ): """Org admin should be able to update their organization.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}", json={ "display_name": "Org Admin Updated Name", - "permissions": ["auth:org:admin"], # Keep org admin perm }, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) @@ -378,18 +377,13 @@ class TestAdminOrganizations: """Org admin cannot remove their org admin permission from org's permissions.""" # The auth:org:admin perm is already created and added by org_admin_permission fixture - # Try to remove all permissions including org admin perm - response = await client.put( - f"/auth/api/admin/orgs/{test_org.uuid}", - json={ - "display_name": "Try Remove Own Perm", - "permissions": [], # Remove org admin perm from org's permissions - }, + # Try to remove org admin perm (this is validated server-side in the remove endpoint) + response = await client.delete( + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:org:admin", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) - assert response.status_code == 400 - data = response.json() - assert "Cannot remove organization admin permission" in data["detail"] + # This should fail because only global admin can remove perms from org + assert response.status_code == 403 @pytest.mark.asyncio async def test_delete_org_own_org_fails( @@ -626,7 +620,7 @@ class TestAdminRoles: self, client: httpx.AsyncClient, session_token: str, test_org, test_role ): """Admin should be able to update a role.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}", json={"display_name": "Updated Role Name"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -640,7 +634,7 @@ class TestAdminRoles: self, client: httpx.AsyncClient, session_token: str, test_org, second_org_role ): """Cannot update role from another org.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}", json={"display_name": "Try Update Wrong Org"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -659,9 +653,8 @@ class TestAdminRoles: grantable_permission, ): """Admin should be able to add grantable permissions to role.""" - response = await client.put( - f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}", - json={"permissions": [grantable_permission.scope]}, + response = await client.post( + f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/{grantable_permission.scope}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -685,9 +678,8 @@ class TestAdminRoles: ) test_db.create_permission(perm) - response = await client.put( - f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}", - json={"permissions": ["test:not:grantable:update"]}, + response = await client.post( + f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/test:not:grantable:update", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 400 @@ -699,14 +691,22 @@ class TestAdminRoles: self, client: httpx.AsyncClient, session_token: str, test_org, test_role ): """Admin cannot remove their own admin permissions.""" - response = await client.put( - f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}", - json={"permissions": []}, # Remove all permissions + # test_role has both auth:admin and auth:org:admin + # Remove auth:admin first (should succeed since org:admin remains) + response = await client.delete( + f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}/permissions/auth:admin", + headers={**auth_headers(session_token), "Host": "localhost:4401"}, + ) + assert response.status_code == 200 + + # Now try to remove auth:org:admin (should fail - would leave no admin access) + response = await client.delete( + f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}/permissions/auth:org:admin", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 400 data = response.json() - assert "Cannot update your own role" in data["detail"] + assert "Cannot remove your own admin permissions" in data["detail"] @pytest.mark.asyncio async def test_delete_role( @@ -863,7 +863,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, test_user ): """Admin should be able to update user display name.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", json={"display_name": "Updated Admin Name"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -876,7 +876,7 @@ class TestAdminUsersInOrg: ): """Updating non-existent user should return 404.""" fake_uuid = uuid7.create() - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/display-name", json={"display_name": "New Name"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -890,7 +890,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user ): """Updating user from another org should return 404.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/display-name", json={"display_name": "New Name"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -902,7 +902,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, test_user ): """Updating user with empty display name should fail.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", json={"display_name": " "}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -916,7 +916,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, test_user ): """Updating user with too long display name should fail.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", json={"display_name": "x" * 100}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -936,7 +936,7 @@ class TestAdminUsersInOrg: ): """Admin should be able to change user's role within org.""" # Use regular_user who is in the same org but not the session owner - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{regular_user.uuid}/role", json={"role": user_role.display_name}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -948,7 +948,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, test_user ): """Updating user role without specifying role should fail.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role", json={}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -963,7 +963,7 @@ class TestAdminUsersInOrg: ): """Updating role for non-existent user should fail.""" fake_uuid = uuid7.create() - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/role", json={"role": "User Role"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -977,7 +977,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user ): """Updating role for user in another org should fail.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/role", json={"role": "User Role"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -991,7 +991,7 @@ class TestAdminUsersInOrg: self, client: httpx.AsyncClient, session_token: str, test_org, test_user ): """Updating user to non-existent role should fail.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role", json={"role": "Nonexistent Role"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -1010,7 +1010,7 @@ class TestAdminUsersInOrg: user_role, ): """Admin cannot change their own role to non-admin role.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}/role", json={"role": user_role.display_name}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, @@ -1031,7 +1031,7 @@ class TestAdminUsersInOrg: """Admin can change their own role to another admin role.""" # test_user is already on test_role which has auth:admin # Changing to the same role should succeed (no permission loss) - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role", json={"role": test_role.display_name}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -1382,7 +1382,7 @@ class TestAdminPermissions: ) test_db.create_permission(perm) - response = await client.put( + response = await client.patch( "/auth/api/admin/permission?permission_id=test:updateable&display_name=Updated%20Name", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) @@ -1403,7 +1403,7 @@ class TestAdminPermissions: ) test_db.create_permission(perm) - response = await client.put( + response = await client.patch( "/auth/api/admin/permission?permission_id=test:perm&display_name=", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) @@ -1626,7 +1626,7 @@ class TestOrgAdminAuthExceptions: test_user, ): """Regular user trying to update display name should get 403.""" - response = await client.put( + response = await client.patch( f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", json={"display_name": "New Name"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, diff --git a/tests/test_user.py b/tests/test_user.py index 04ab84a..8c2c1af 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -16,12 +16,12 @@ from tests.conftest import auth_headers class TestUserDisplayName: - """Tests for PUT /auth/api/user/display-name""" + """Tests for PATCH /auth/api/user/display-name""" @pytest.mark.asyncio async def test_update_display_name_requires_auth(self, client: httpx.AsyncClient): """Update display name without auth should return 401.""" - response = await client.put( + response = await client.patch( "/auth/api/user/display-name", json={"display_name": "New Name"}, ) @@ -32,7 +32,7 @@ class TestUserDisplayName: self, client: httpx.AsyncClient, session_token: str ): """User should be able to update their display name.""" - response = await client.put( + response = await client.patch( "/auth/api/user/display-name", json={"display_name": "Updated Name"}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -46,7 +46,7 @@ class TestUserDisplayName: self, client: httpx.AsyncClient, session_token: str ): """Empty display name should fail.""" - response = await client.put( + response = await client.patch( "/auth/api/user/display-name", json={"display_name": ""}, headers={**auth_headers(session_token), "Host": "localhost:4401"}, @@ -59,7 +59,7 @@ class TestUserDisplayName: ): """Display name over 64 chars should fail.""" long_name = "x" * 100 - response = await client.put( + response = await client.patch( "/auth/api/user/display-name", json={"display_name": long_name}, headers={**auth_headers(session_token), "Host": "localhost:4401"},