From 7f2911231a7e4c287c62ca3e3ca8f4edcf7818fc Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Mon, 10 Aug 2026 02:00:40 +0200 Subject: [PATCH] Expose member permissions so manage-all survives a write The write endpoints derive the access_all flag from the raw type plus the three collection permissions, but the member serializer emitted neither the custom role nor the permissions object. A client that read a manage-all member and wrote it back unchanged therefore dropped that member from every collection, because the information needed to round-trip was not in the response. The member object now reports the custom role and its permissions the same way the internal serializers do, which is also the shape upstream uses: Permissions is part of the shared member model and is returned on reads as well as accepted on writes. Co-Authored-By: Claude Opus 5 --- scripts/smoke_public_api_write.sh | 33 +++++++++++++++++++++++++++++++ src/api/core/public.rs | 29 ++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/scripts/smoke_public_api_write.sh b/scripts/smoke_public_api_write.sh index 47db57a4..18540d5a 100755 --- a/scripts/smoke_public_api_write.sh +++ b/scripts/smoke_public_api_write.sh @@ -395,6 +395,39 @@ check_eq "restoring an active member -> 400" "$HTTP_CODE" "400" req POST "/api/public/members/$NEWMEMBER/restore" "$TOKEN" check_eq "restoring an invited member -> 400" "$HTTP_CODE" "400" +echo "" +echo "== A manage-all member survives a read-modify-write ==" + +# Vaultwarden stores manage-all as the access_all flag on a Manager. It is exposed as the +# custom role plus its three collection permissions, which is the only shape a client can +# read and send back without silently dropping the access. +PERMS='{"accessEventLogs":false,"accessImportExport":false,"accessReports":false,"createNewCollections":true,"editAnyCollection":true,"deleteAnyCollection":true,"manageGroups":false,"managePolicies":false,"manageSso":false,"manageUsers":false,"manageResetPassword":false,"manageScim":false}' + +reqj POST "/api/public/members" "$TOKEN" \ + "{\"email\":\"manageall@example.com\",\"type\":4,\"permissions\":$PERMS}" +check_eq "create a manage-all member -> 200" "$HTTP_CODE" "200" +MANAGEALL=$(jqval '.id') +jqcheck "manage-all member reports the custom role" '.type' "4" +jqcheck "manage-all member reports its permissions" '.permissions.editAnyCollection' "true" + +# Read it back and send exactly that back again, which is what a sync client does. +req GET "/api/public/members/$MANAGEALL" "$TOKEN" +jqcheck "manage-all member still reports the custom role" '.type' "4" +ROUNDTRIP=$(jq -c '{type: .type, externalId: .externalId, permissions: .permissions}' "$TMP/body") +reqj PUT "/api/public/members/$MANAGEALL" "$TOKEN" "$ROUNDTRIP" +check_eq "write the member back unchanged -> 200" "$HTTP_CODE" "200" + +req GET "/api/public/members/$MANAGEALL" "$TOKEN" +jqcheck "round-trip kept the custom role" '.type' "4" +jqcheck "round-trip kept manage-all" '.permissions.editAnyCollection' "true" + +# A plain member carries no permissions object. +req GET "/api/public/members/$MEMBER3" "$TOKEN" +jqcheck "a plain member has null permissions" '.permissions' "null" + +req DELETE "/api/public/members/$MANAGEALL" "$TOKEN" +check_eq "clean up the manage-all member -> 200" "$HTTP_CODE" "200" + echo "" echo "== Ownership is out of reach for a Public API client ==" diff --git a/src/api/core/public.rs b/src/api/core/public.rs index d790274f..310d6391 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -252,16 +252,43 @@ async fn member_to_json(member: &Membership, conn: &DbConn) -> Value { member.status }; + // HACK: Convert the manager type to a custom type, the same way the internal + // serializers do. Vaultwarden has no real custom role, it links the three collection + // permissions to the access_all flag instead, and the write endpoints read that flag + // back out of exactly this shape. Emitting both is what lets a client read a member, + // send it back unchanged, and keep its access. + let membership_type = member.type_manager_as_custom(); + let permissions = if membership_type == 4 && member.access_all { + json!({ + "accessEventLogs": false, + "accessImportExport": false, + "accessReports": false, + // If the following 3 Collection roles are set to true a custom user has access all permission + "createNewCollections": true, + "editAnyCollection": true, + "deleteAnyCollection": true, + "manageGroups": false, + "managePolicies": false, + "manageSso": false, // Not supported + "manageUsers": false, + "manageResetPassword": false, + "manageScim": false // Not supported (Not AGPLv3 Licensed) + }) + } else { + json!(null) + }; + json!({ "object": "member", "id": member.uuid, "userId": member.user_uuid, "name": name, "email": email, - "type": member.atype, + "type": membership_type, "externalId": member.external_id, "resetPasswordEnrolled": member.reset_password_key.is_some(), "status": status, + "permissions": permissions, }) }