Browse Source

Merge c9c81f3b02 into 0cefa4cca7

pull/7569/merge
Rune Darrud 5 days ago
committed by GitHub
parent
commit
73b0e51fac
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 310
      scripts/smoke_public_api.sh
  2. 527
      scripts/smoke_public_api_write.sh
  3. 28
      src/api/core/events.rs
  4. 885
      src/api/core/public.rs

310
scripts/smoke_public_api.sh

@ -0,0 +1,310 @@
#!/usr/bin/env bash
#
# Smoke test for the organization Public API read endpoints.
#
# Boots a throwaway Vaultwarden instance against a temporary SQLite database
# seeded with two organizations (each with its own members, groups, collections
# and access associations), mints an organization API token for the first org,
# then exercises every read endpoint and asserts on the response shapes.
#
# It also asserts the organization scoping boundary: ids that belong to the
# second organization must return HTTP 404 through the first org's token, and a
# request with no token must return HTTP 401.
#
# The script exits non-zero if any assertion fails, so it is usable as a check.
#
# Requirements: bash, curl, jq, sqlite3, and either a prebuilt binary passed via
# the VW_BIN environment variable or a cargo toolchain to build one.
#
# Usage:
# scripts/smoke_public_api.sh
# VW_BIN=/path/to/vaultwarden PORT=8123 scripts/smoke_public_api.sh
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
cd "$REPO_ROOT"
PORT="${PORT:-8079}"
VW_BIN="${VW_BIN:-$REPO_ROOT/target/debug/vaultwarden}"
API="http://127.0.0.1:$PORT"
# ---- fixtures -------------------------------------------------------------
ORG=22222222-2222-4222-8222-222222222222
ORG2=99999999-9999-4999-8999-999999999999
USER=11111111-1111-4111-8111-111111111111
USER2=88888888-8888-4888-8888-888888888888
MEMBER=33333333-3333-4333-8333-333333333333
MEMBER2=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa
GROUP=44444444-4444-4444-8444-444444444444
GROUP2=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb
COLLECTION=55555555-5555-4555-8555-555555555555
COLLECTION2=66666666-6666-4666-8666-666666666666
APIKEYUUID=77777777-7777-4777-8777-777777777777
APIKEY=smoketestapikey1234567890
# ---- prerequisites --------------------------------------------------------
for tool in curl jq sqlite3; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool '$tool' is not installed" >&2
exit 2
fi
done
if [ ! -x "$VW_BIN" ]; then
if command -v cargo >/dev/null 2>&1; then
echo "Building vaultwarden (sqlite feature); this can take a while..."
cargo build --features sqlite
else
echo "ERROR: no binary at '$VW_BIN' and no cargo toolchain to build one." >&2
echo "Set VW_BIN to a prebuilt binary or install a Rust toolchain." >&2
exit 2
fi
fi
# ---- workspace + cleanup --------------------------------------------------
TMP=$(mktemp -d)
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP"
}
trap cleanup EXIT
export DATA_FOLDER="$TMP"
export DATABASE_URL="sqlite://$TMP/db.sqlite3"
export ADMIN_TOKEN="smoketestadmintoken"
export ORG_GROUPS_ENABLED=true
export WEB_VAULT_ENABLED=false
export ROCKET_PORT="$PORT"
export ROCKET_ADDRESS=127.0.0.1
export DOMAIN="http://localhost:$PORT"
# ---- server helpers -------------------------------------------------------
start_server() {
local logfile="$1"
"$VW_BIN" >"$logfile" 2>&1 &
SERVER_PID=$!
local i
for i in $(seq 1 90); do
if grep -q "Rocket has launched" "$logfile" 2>/dev/null; then
return 0
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "ERROR: server exited during startup. Log:" >&2
cat "$logfile" >&2
return 1
fi
sleep 1
done
echo "ERROR: server did not launch within 90s. Log:" >&2
cat "$logfile" >&2
return 1
}
stop_server() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
fi
}
# ---- assertion helpers ----------------------------------------------------
FAILS=0
pass() { printf 'PASS: %s\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1"; FAILS=$((FAILS + 1)); }
check_eq() { # label actual expected
if [ "$2" = "$3" ]; then
pass "$1"
else
fail "$1 (expected [$3], got [$2])"
fi
}
# req METHOD PATH [TOKEN] -> sets HTTP_CODE, body written to $TMP/body
req() {
local method="$1" path="$2" token="${3:-}"
if [ -n "$token" ]; then
HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \
-X "$method" -H "Authorization: Bearer $token" "$API$path")
else
HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' -X "$method" "$API$path")
fi
}
jqval() { jq -r "$1" "$TMP/body"; }
jqcheck() { # label filter expected
check_eq "$1" "$(jqval "$2")" "$3"
}
# ---- boot once to run migrations, then seed, then boot to serve -----------
echo "== Booting once to create the database schema =="
start_server "$TMP/boot1.log"
stop_server
echo "== Seeding two organizations with members, groups and collections =="
sqlite3 "$TMP/db.sqlite3" <<SQL
INSERT INTO users (uuid,enabled,created_at,updated_at,login_verify_count,email,name,password_hash,salt,password_iterations,akey,security_stamp,equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter)
VALUES
('$USER',1,'2026-01-01 00:00:00','2026-01-01 00:00:00',0,'alice@example.com','Alice Example',X'00',X'00',100000,'','stamp-1','[]','[]',0,100000),
('$USER2',1,'2026-01-01 00:00:00','2026-01-01 00:00:00',0,'bob@example.com','Bob Other',X'00',X'00',100000,'','stamp-2','[]','[]',0,100000);
INSERT INTO organizations (uuid,name,billing_email,private_key,public_key) VALUES
('$ORG','Test Org','billing@example.com',NULL,NULL),
('$ORG2','Other Org','other@example.com',NULL,NULL);
INSERT INTO organization_api_key (uuid,org_uuid,atype,api_key,revision_date) VALUES
('$APIKEYUUID','$ORG',0,'$APIKEY','2026-01-01 00:00:00');
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,invited_by_email,access_all,akey,status,atype,reset_password_key,external_id) VALUES
('$MEMBER','$USER','$ORG',NULL,0,'',2,2,NULL,'ext-member-1'),
('$MEMBER2','$USER2','$ORG2',NULL,0,'',2,2,NULL,'ext-member-2');
INSERT INTO groups (uuid,organizations_uuid,name,access_all,external_id,creation_date,revision_date) VALUES
('$GROUP','$ORG','Engineering',0,'ext-group-1','2026-01-01 00:00:00','2026-01-01 00:00:00'),
('$GROUP2','$ORG2','Other Group',0,'ext-group-2','2026-01-01 00:00:00','2026-01-01 00:00:00');
INSERT INTO groups_users (groups_uuid,users_organizations_uuid) VALUES
('$GROUP','$MEMBER');
INSERT INTO collections (uuid,org_uuid,name,external_id) VALUES
('$COLLECTION','$ORG','2.encryptedCiphertextName==','ext-collection-1'),
('$COLLECTION2','$ORG2','2.otherOrgCiphertext==','ext-collection-2');
INSERT INTO users_collections (user_uuid,collection_uuid,read_only,hide_passwords,manage) VALUES
('$USER','$COLLECTION',1,0,0);
INSERT INTO collections_groups (collections_uuid,groups_uuid,read_only,hide_passwords,manage) VALUES
('$COLLECTION','$GROUP',0,0,1);
SQL
echo "== Booting to serve =="
start_server "$TMP/boot2.log"
echo "== Minting an organization API token =="
TOKEN=$(curl -sS -X POST "$API/identity/connect/token" \
-d 'grant_type=client_credentials' \
-d "client_id=organization.$ORG" \
-d "client_secret=$APIKEY" \
-d 'scope=api.organization' \
-d 'device_identifier=dddddddd-dddd-4ddd-8ddd-dddddddddddd' \
-d 'device_name=smoketest' \
-d 'device_type=14' | jq -r '.access_token // empty')
if [ -z "$TOKEN" ]; then
echo "FAIL: could not mint an organization API token" >&2
exit 1
fi
pass "minted organization API token"
echo ""
echo "== Read endpoints =="
# 1. Members list (without collections/groups).
req GET "/api/public/members" "$TOKEN"
check_eq "members list -> 200" "$HTTP_CODE" "200"
jqcheck "members list is a list object" '.object' "list"
jqcheck "members list has continuationToken null" '.continuationToken' "null"
jqcheck "members list has one member" '.data | length' "1"
jqcheck "member object discriminator" '.data[0].object' "member"
jqcheck "member id" '.data[0].id' "$MEMBER"
jqcheck "member userId" '.data[0].userId' "$USER"
jqcheck "member email" '.data[0].email' "alice@example.com"
jqcheck "member name" '.data[0].name' "Alice Example"
jqcheck "member type" '.data[0].type' "2"
jqcheck "member status" '.data[0].status' "2"
jqcheck "member externalId" '.data[0].externalId' "ext-member-1"
jqcheck "member resetPasswordEnrolled" '.data[0].resetPasswordEnrolled' "false"
jqcheck "members list omits collections" '.data[0] | has("collections")' "false"
# 2. Member detail (with direct collection grants).
req GET "/api/public/members/$MEMBER" "$TOKEN"
check_eq "member detail -> 200" "$HTTP_CODE" "200"
jqcheck "member detail id" '.id' "$MEMBER"
jqcheck "member detail carries one collection grant" '.collections | length' "1"
jqcheck "member detail collection id" '.collections[0].id' "$COLLECTION"
jqcheck "member detail collection readOnly" '.collections[0].readOnly' "true"
jqcheck "member detail collection hidePasswords" '.collections[0].hidePasswords' "false"
jqcheck "member detail collection manage" '.collections[0].manage' "false"
# 3. Member group ids.
req GET "/api/public/members/$MEMBER/group-ids" "$TOKEN"
check_eq "member group-ids -> 200" "$HTTP_CODE" "200"
jqcheck "member group-ids is a bare array of one" 'length' "1"
jqcheck "member group-ids contains the group" '.[0]' "$GROUP"
# 4. Groups list.
req GET "/api/public/groups" "$TOKEN"
check_eq "groups list -> 200" "$HTTP_CODE" "200"
jqcheck "groups list is a list object" '.object' "list"
jqcheck "groups list has one group" '.data | length' "1"
jqcheck "group object discriminator" '.data[0].object' "group"
jqcheck "group id" '.data[0].id' "$GROUP"
jqcheck "group name is plaintext" '.data[0].name' "Engineering"
jqcheck "group accessAll" '.data[0].accessAll' "false"
jqcheck "group externalId" '.data[0].externalId' "ext-group-1"
# 5. Group detail (with collection grants).
req GET "/api/public/groups/$GROUP" "$TOKEN"
check_eq "group detail -> 200" "$HTTP_CODE" "200"
jqcheck "group detail id" '.id' "$GROUP"
jqcheck "group detail carries one collection grant" '.collections | length' "1"
jqcheck "group detail collection id" '.collections[0].id' "$COLLECTION"
jqcheck "group detail collection readOnly" '.collections[0].readOnly' "false"
jqcheck "group detail collection manage" '.collections[0].manage' "true"
# 6. Group member ids.
req GET "/api/public/groups/$GROUP/member-ids" "$TOKEN"
check_eq "group member-ids -> 200" "$HTTP_CODE" "200"
jqcheck "group member-ids is a bare array of one" 'length' "1"
jqcheck "group member-ids contains the membership" '.[0]' "$MEMBER"
# 7. Collections list (id + externalId only, no name).
req GET "/api/public/collections" "$TOKEN"
check_eq "collections list -> 200" "$HTTP_CODE" "200"
jqcheck "collections list is a list object" '.object' "list"
jqcheck "collections list has one collection" '.data | length' "1"
jqcheck "collection object discriminator" '.data[0].object' "collection"
jqcheck "collection id" '.data[0].id' "$COLLECTION"
jqcheck "collection externalId" '.data[0].externalId' "ext-collection-1"
jqcheck "collection omits the encrypted name" '.data[0] | has("name")' "false"
# 8. Collection detail (with group grants).
req GET "/api/public/collections/$COLLECTION" "$TOKEN"
check_eq "collection detail -> 200" "$HTTP_CODE" "200"
jqcheck "collection detail id" '.id' "$COLLECTION"
jqcheck "collection detail externalId" '.externalId' "ext-collection-1"
jqcheck "collection detail omits the encrypted name" 'has("name")' "false"
jqcheck "collection detail carries one group grant" '.groups | length' "1"
jqcheck "collection detail group id" '.groups[0].id' "$GROUP"
jqcheck "collection detail group manage" '.groups[0].manage' "true"
echo ""
echo "== Organization scoping boundary =="
# Ids that exist but belong to the other organization must not resolve.
req GET "/api/public/members/$MEMBER2" "$TOKEN"
check_eq "cross-org member -> 404" "$HTTP_CODE" "404"
req GET "/api/public/groups/$GROUP2" "$TOKEN"
check_eq "cross-org group -> 404" "$HTTP_CODE" "404"
req GET "/api/public/collections/$COLLECTION2" "$TOKEN"
check_eq "cross-org collection -> 404" "$HTTP_CODE" "404"
echo ""
echo "== Authentication required =="
req GET "/api/public/members"
check_eq "no token -> 401" "$HTTP_CODE" "401"
echo ""
if [ "$FAILS" -ne 0 ]; then
echo "RESULT: $FAILS assertion(s) failed."
exit 1
fi
echo "RESULT: all assertions passed."

527
scripts/smoke_public_api_write.sh

@ -0,0 +1,527 @@
#!/usr/bin/env bash
#
# Smoke test for the organization Public API member and group write endpoints.
#
# Boots a throwaway Vaultwarden instance against a temporary SQLite database
# seeded with two organizations, mints an organization API token for the first
# org, then exercises every write endpoint and asserts on the resulting state.
#
# Beyond the happy paths it asserts the guards that protect organization
# integrity: the last confirmed owner cannot be demoted, revoked or deleted;
# collections, groups and members from another organization are rejected; ids
# belonging to the second organization return HTTP 404; and a request with no
# token returns HTTP 401. It also asserts that a group update leaves member
# assignments alone, and that every write is recorded in the event log with no
# acting user, since a Public API client is not a user.
#
# The script exits non-zero if any assertion fails, so it is usable as a check.
#
# Requirements: bash, curl, jq, sqlite3, and either a prebuilt binary passed via
# the VW_BIN environment variable or a cargo toolchain to build one.
#
# Usage:
# scripts/smoke_public_api_write.sh
# VW_BIN=/path/to/vaultwarden PORT=8123 scripts/smoke_public_api_write.sh
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
cd "$REPO_ROOT"
PORT="${PORT:-8082}"
VW_BIN="${VW_BIN:-$REPO_ROOT/target/debug/vaultwarden}"
API="http://127.0.0.1:$PORT"
# ---- fixtures -------------------------------------------------------------
ORG=22222222-2222-4222-8222-222222222222
ORG2=99999999-9999-4999-8999-999999999999
USER=11111111-1111-4111-8111-111111111111
USER2=88888888-8888-4888-8888-888888888888
USER3=cccccccc-cccc-4ccc-8ccc-cccccccccccc
# MEMBER is the only confirmed owner, so it is the one the guards protect.
MEMBER=33333333-3333-4333-8333-333333333333
MEMBER2=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa
MEMBER3=dddddddd-dddd-4ddd-8ddd-dddddddddddd
GROUP=44444444-4444-4444-8444-444444444444
GROUP2=bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb
COLLECTION=55555555-5555-4555-8555-555555555555
COLLECTION2=66666666-6666-4666-8666-666666666666
APIKEYUUID=77777777-7777-4777-8777-777777777777
APIKEY=smoketestapikey1234567890
NEW_EMAIL=newmember@example.com
# ---- prerequisites --------------------------------------------------------
for tool in curl jq sqlite3; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool '$tool' is not installed" >&2
exit 2
fi
done
if [ ! -x "$VW_BIN" ]; then
if command -v cargo >/dev/null 2>&1; then
echo "Building vaultwarden (sqlite feature); this can take a while..."
cargo build --features sqlite
else
echo "ERROR: no binary at '$VW_BIN' and no cargo toolchain to build one." >&2
echo "Set VW_BIN to a prebuilt binary or install a Rust toolchain." >&2
exit 2
fi
fi
# ---- workspace + cleanup --------------------------------------------------
TMP=$(mktemp -d)
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP"
}
trap cleanup EXIT
export DATA_FOLDER="$TMP"
export DATABASE_URL="sqlite://$TMP/db.sqlite3"
export ADMIN_TOKEN="smoketestadmintoken"
export ORG_GROUPS_ENABLED=true
export ORG_EVENTS_ENABLED=true
export INVITATIONS_ALLOWED=true
export WEB_VAULT_ENABLED=false
export ROCKET_PORT="$PORT"
export ROCKET_ADDRESS=127.0.0.1
export DOMAIN="http://localhost:$PORT"
# ---- server helpers -------------------------------------------------------
start_server() {
local logfile="$1"
"$VW_BIN" >"$logfile" 2>&1 &
SERVER_PID=$!
local i
for i in $(seq 1 90); do
if grep -q "Rocket has launched" "$logfile" 2>/dev/null; then
return 0
fi
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "ERROR: server exited during startup. Log:" >&2
cat "$logfile" >&2
return 1
fi
sleep 1
done
echo "ERROR: server did not launch within 90s. Log:" >&2
cat "$logfile" >&2
return 1
}
stop_server() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
fi
}
# ---- assertion helpers ----------------------------------------------------
FAILS=0
pass() { printf 'PASS: %s\n' "$1"; }
fail() { printf 'FAIL: %s\n' "$1"; FAILS=$((FAILS + 1)); }
check_eq() { # label actual expected
if [ "$2" = "$3" ]; then
pass "$1"
else
fail "$1 (expected [$3], got [$2])"
fi
}
# req METHOD PATH [TOKEN] -> sets HTTP_CODE, body written to $TMP/body
req() {
local method="$1" path="$2" token="${3:-}"
if [ -n "$token" ]; then
HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \
-X "$method" -H "Authorization: Bearer $token" "$API$path")
else
HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' -X "$method" "$API$path")
fi
}
# reqj METHOD PATH TOKEN JSON -> same, with a JSON request body
reqj() {
local method="$1" path="$2" token="$3" body="$4"
if [ -n "$token" ]; then
HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \
-X "$method" -H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' -d "$body" "$API$path")
else
HTTP_CODE=$(curl -sS -o "$TMP/body" -w '%{http_code}' \
-X "$method" -H 'Content-Type: application/json' -d "$body" "$API$path")
fi
}
jqval() { jq -r "$1" "$TMP/body"; }
jqcheck() { # label filter expected
check_eq "$1" "$(jqval "$2")" "$3"
}
sqlcheck() { # label sql expected
check_eq "$1" "$(sqlite3 "$TMP/db.sqlite3" "$2")" "$3"
}
# ---- boot once to run migrations, then seed, then boot to serve -----------
echo "== Booting once to create the database schema =="
start_server "$TMP/boot1.log"
stop_server
echo "== Seeding two organizations with members, groups and collections =="
sqlite3 "$TMP/db.sqlite3" <<SQL
INSERT INTO users (uuid,enabled,created_at,updated_at,login_verify_count,email,name,password_hash,salt,password_iterations,akey,security_stamp,equivalent_domains,excluded_globals,client_kdf_type,client_kdf_iter)
VALUES
('$USER',1,'2026-01-01 00:00:00','2026-01-01 00:00:00',0,'alice@example.com','Alice Example',X'00',X'00',100000,'','stamp-1','[]','[]',0,100000),
('$USER2',1,'2026-01-01 00:00:00','2026-01-01 00:00:00',0,'bob@example.com','Bob Other',X'00',X'00',100000,'','stamp-2','[]','[]',0,100000),
('$USER3',1,'2026-01-01 00:00:00','2026-01-01 00:00:00',0,'carol@example.com','Carol Example',X'00',X'00',100000,'','stamp-3','[]','[]',0,100000);
INSERT INTO organizations (uuid,name,billing_email,private_key,public_key) VALUES
('$ORG','Test Org','billing@example.com',NULL,NULL),
('$ORG2','Other Org','other@example.com',NULL,NULL);
INSERT INTO organization_api_key (uuid,org_uuid,atype,api_key,revision_date) VALUES
('$APIKEYUUID','$ORG',0,'$APIKEY','2026-01-01 00:00:00');
-- atype 0 is Owner and 2 is User; status 2 is Confirmed.
INSERT INTO users_organizations (uuid,user_uuid,org_uuid,invited_by_email,access_all,akey,status,atype,reset_password_key,external_id) VALUES
('$MEMBER','$USER','$ORG',NULL,0,'',2,0,NULL,'ext-member-1'),
('$MEMBER2','$USER2','$ORG2',NULL,0,'',2,2,NULL,'ext-member-2'),
('$MEMBER3','$USER3','$ORG',NULL,0,'',2,2,NULL,'ext-member-3');
INSERT INTO groups (uuid,organizations_uuid,name,access_all,external_id,creation_date,revision_date) VALUES
('$GROUP','$ORG','Engineering',0,'ext-group-1','2026-01-01 00:00:00','2026-01-01 00:00:00'),
('$GROUP2','$ORG2','Other Group',0,'ext-group-2','2026-01-01 00:00:00','2026-01-01 00:00:00');
INSERT INTO collections (uuid,org_uuid,name,external_id) VALUES
('$COLLECTION','$ORG','2.encryptedCiphertextName==','ext-collection-1'),
('$COLLECTION2','$ORG2','2.otherOrgCiphertext==','ext-collection-2');
SQL
echo "== Booting to serve =="
start_server "$TMP/boot2.log"
echo "== Minting an organization API token =="
TOKEN=$(curl -sS -X POST "$API/identity/connect/token" \
-d 'grant_type=client_credentials' \
-d "client_id=organization.$ORG" \
-d "client_secret=$APIKEY" \
-d 'scope=api.organization' \
-d 'device_identifier=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' \
-d 'device_name=smoketest' \
-d 'device_type=14' | jq -r '.access_token // empty')
if [ -z "$TOKEN" ]; then
echo "FAIL: could not mint an organization API token" >&2
exit 1
fi
pass "minted organization API token"
echo ""
echo "== Create a group =="
reqj POST "/api/public/groups" "$TOKEN" \
"{\"name\":\"Platform\",\"externalId\":\"ext-new-group\",\"collections\":[{\"id\":\"$COLLECTION\",\"readOnly\":true,\"hidePasswords\":false,\"manage\":false}]}"
check_eq "create group -> 200" "$HTTP_CODE" "200"
jqcheck "created group discriminator" '.object' "group"
jqcheck "created group name" '.name' "Platform"
jqcheck "created group externalId" '.externalId' "ext-new-group"
jqcheck "created group accessAll defaults to false" '.accessAll' "false"
NEWGROUP=$(jqval '.id')
req GET "/api/public/groups/$NEWGROUP" "$TOKEN"
check_eq "created group is readable -> 200" "$HTTP_CODE" "200"
jqcheck "created group kept its collection grant" '.collections | length' "1"
jqcheck "created group collection id" '.collections[0].id' "$COLLECTION"
jqcheck "created group collection readOnly" '.collections[0].readOnly' "true"
echo ""
echo "== Group input validation =="
reqj POST "/api/public/groups" "$TOKEN" \
"{\"name\":\"Bad\",\"collections\":[{\"id\":\"$COLLECTION2\"}]}"
check_eq "group with another org's collection -> 400" "$HTTP_CODE" "400"
echo ""
echo "== Update a group =="
reqj PUT "/api/public/groups/$NEWGROUP" "$TOKEN" \
"{\"name\":\"Platform Team\",\"externalId\":\"ext-new-group-2\",\"collections\":[]}"
check_eq "update group -> 200" "$HTTP_CODE" "200"
jqcheck "updated group name" '.name' "Platform Team"
jqcheck "updated group externalId" '.externalId' "ext-new-group-2"
req GET "/api/public/groups/$NEWGROUP" "$TOKEN"
jqcheck "update replaced the collection grants" '.collections | length' "0"
echo ""
echo "== Group member ids =="
reqj PUT "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN" "{\"memberIds\":[\"$MEMBER3\"]}"
check_eq "set group member-ids -> 200" "$HTTP_CODE" "200"
req GET "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN"
jqcheck "group has one member" 'length' "1"
jqcheck "group member is the expected membership" '.[0]' "$MEMBER3"
reqj PUT "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN" "{\"memberIds\":[\"$MEMBER2\"]}"
check_eq "group member-ids from another org -> 400" "$HTTP_CODE" "400"
# Members are owned by the member-ids endpoint, so a group update must leave
# them alone. The internal endpoint clears them, this one deliberately does not.
reqj PUT "/api/public/groups/$NEWGROUP" "$TOKEN" "{\"name\":\"Platform Team\",\"collections\":[]}"
check_eq "update group again -> 200" "$HTTP_CODE" "200"
req GET "/api/public/groups/$NEWGROUP/member-ids" "$TOKEN"
jqcheck "group update left member assignments intact" 'length' "1"
echo ""
echo "== accessAll survives an update that omits it =="
reqj POST "/api/public/groups" "$TOKEN" "{\"name\":\"Full Access\",\"accessAll\":true,\"collections\":[]}"
check_eq "create an accessAll group -> 200" "$HTTP_CODE" "200"
jqcheck "created group has accessAll" '.accessAll' "true"
AAGROUP=$(jqval '.id')
reqj PUT "/api/public/groups/$AAGROUP" "$TOKEN" "{\"name\":\"Full Access Renamed\",\"collections\":[]}"
check_eq "rename without accessAll -> 200" "$HTTP_CODE" "200"
jqcheck "omitted accessAll is preserved" '.accessAll' "true"
req DELETE "/api/public/groups/$AAGROUP" "$TOKEN"
check_eq "clean up the accessAll group -> 200" "$HTTP_CODE" "200"
echo ""
echo "== Create a member =="
reqj POST "/api/public/members" "$TOKEN" \
"{\"email\":\"$NEW_EMAIL\",\"type\":2,\"externalId\":\"ext-new-member\",\"collections\":[{\"id\":\"$COLLECTION\",\"readOnly\":true,\"hidePasswords\":false,\"manage\":false}],\"groups\":[\"$GROUP\"]}"
check_eq "create member -> 200" "$HTTP_CODE" "200"
jqcheck "created member discriminator" '.object' "member"
jqcheck "created member email" '.email' "$NEW_EMAIL"
jqcheck "created member type" '.type' "2"
jqcheck "created member externalId" '.externalId' "ext-new-member"
jqcheck "created member is invited" '.status' "0"
NEWMEMBER=$(jqval '.id')
req GET "/api/public/members/$NEWMEMBER" "$TOKEN"
jqcheck "created member kept its collection grant" '.collections | length' "1"
jqcheck "created member collection readOnly" '.collections[0].readOnly' "true"
req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN"
jqcheck "created member joined the group" 'length' "1"
jqcheck "created member group id" '.[0]' "$GROUP"
echo ""
echo "== Member input validation =="
reqj POST "/api/public/members" "$TOKEN" "{\"email\":\"$NEW_EMAIL\",\"type\":2}"
check_eq "duplicate member email -> 400" "$HTTP_CODE" "400"
reqj POST "/api/public/members" "$TOKEN" \
"{\"email\":\"other@example.com\",\"type\":2,\"groups\":[\"$GROUP2\"]}"
check_eq "member with another org's group -> 400" "$HTTP_CODE" "400"
reqj POST "/api/public/members" "$TOKEN" "{\"email\":\"bad@example.com\",\"type\":99}"
check_eq "member with an unknown type -> 400" "$HTTP_CODE" "400"
echo ""
echo "== Update a member =="
# An omitted groups list must leave group membership alone, and an omitted externalId
# must not clear the directory matching key.
reqj PUT "/api/public/members/$NEWMEMBER" "$TOKEN" \
"{\"type\":2,\"collections\":[{\"id\":\"$COLLECTION\",\"readOnly\":false,\"hidePasswords\":true,\"manage\":false}]}"
check_eq "update member -> 200" "$HTTP_CODE" "200"
jqcheck "omitted externalId is preserved" '.externalId' "ext-new-member"
req GET "/api/public/members/$NEWMEMBER" "$TOKEN"
jqcheck "update replaced the collection grants" '.collections | length' "1"
jqcheck "updated collection readOnly" '.collections[0].readOnly' "false"
jqcheck "updated collection hidePasswords" '.collections[0].hidePasswords' "true"
req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN"
jqcheck "omitted groups leave membership alone" 'length' "1"
# An explicit empty list does clear them.
reqj PUT "/api/public/members/$NEWMEMBER" "$TOKEN" \
"{\"type\":2,\"externalId\":\"ext-updated\",\"collections\":[],\"groups\":[]}"
check_eq "update member with explicit empty groups -> 200" "$HTTP_CODE" "200"
jqcheck "explicit externalId is applied" '.externalId' "ext-updated"
req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN"
jqcheck "explicit empty groups clears membership" 'length' "0"
echo ""
echo "== Member group ids =="
reqj PUT "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" "{\"groupIds\":[\"$GROUP\"]}"
check_eq "set member group-ids -> 200" "$HTTP_CODE" "200"
req GET "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN"
jqcheck "member group-ids applied" '.[0]' "$GROUP"
reqj PUT "/api/public/members/$NEWMEMBER/group-ids" "$TOKEN" "{\"groupIds\":[\"$GROUP2\"]}"
check_eq "member group-ids from another org -> 400" "$HTTP_CODE" "400"
echo ""
echo "== Reinvite =="
req POST "/api/public/members/$NEWMEMBER/reinvite" "$TOKEN"
check_eq "reinvite an invited member -> 200" "$HTTP_CODE" "200"
echo ""
echo "== Revoke and restore =="
req POST "/api/public/members/$MEMBER3/revoke" "$TOKEN"
check_eq "revoke a member -> 200" "$HTTP_CODE" "200"
req GET "/api/public/members/$MEMBER3" "$TOKEN"
jqcheck "revoked member reports the upstream revoked status" '.status' "-1"
req POST "/api/public/members/$MEMBER3/revoke" "$TOKEN"
check_eq "revoking twice -> 400" "$HTTP_CODE" "400"
req POST "/api/public/members/$MEMBER3/restore" "$TOKEN"
check_eq "restore a member -> 200" "$HTTP_CODE" "200"
req GET "/api/public/members/$MEMBER3" "$TOKEN"
jqcheck "restored member is confirmed again" '.status' "2"
req POST "/api/public/members/$MEMBER3/restore" "$TOKEN"
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, and must still round-trip: the read shape
# has to be acceptable as a write body, null permissions and all.
req GET "/api/public/members/$MEMBER3" "$TOKEN"
jqcheck "a plain member has a permissions key" 'has("permissions")' "true"
jqcheck "a plain member has null permissions" '.permissions' "null"
PLAIN=$(jq -c '{type: .type, externalId: .externalId, permissions: .permissions, collections: []}' "$TMP/body")
reqj PUT "/api/public/members/$MEMBER3" "$TOKEN" "$PLAIN"
check_eq "a plain member round-trips too" "$HTTP_CODE" "200"
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 =="
reqj POST "/api/public/members" "$TOKEN" "{\"email\":\"owner@example.com\",\"type\":0}"
check_eq "creating an Owner -> 400" "$HTTP_CODE" "400"
reqj PUT "/api/public/members/$MEMBER3" "$TOKEN" "{\"type\":0}"
check_eq "promoting a member to Owner -> 400" "$HTTP_CODE" "400"
req GET "/api/public/members/$MEMBER3" "$TOKEN"
jqcheck "the member was not promoted" '.type' "2"
reqj PUT "/api/public/members/$MEMBER" "$TOKEN" "{\"type\":2}"
check_eq "demoting an owner -> 400" "$HTTP_CODE" "400"
req DELETE "/api/public/members/$MEMBER" "$TOKEN"
check_eq "deleting an owner -> 400" "$HTTP_CODE" "400"
req POST "/api/public/members/$MEMBER/revoke" "$TOKEN"
check_eq "revoking an owner -> 400" "$HTTP_CODE" "400"
req GET "/api/public/members/$MEMBER" "$TOKEN"
jqcheck "the owner is untouched, type" '.type' "0"
jqcheck "the owner is untouched, status" '.status' "2"
echo ""
echo "== Organization scoping boundary =="
reqj PUT "/api/public/members/$MEMBER2" "$TOKEN" "{\"type\":2}"
check_eq "updating a member of another org -> 404" "$HTTP_CODE" "404"
req DELETE "/api/public/members/$MEMBER2" "$TOKEN"
check_eq "deleting a member of another org -> 404" "$HTTP_CODE" "404"
req POST "/api/public/members/$MEMBER2/revoke" "$TOKEN"
check_eq "revoking a member of another org -> 404" "$HTTP_CODE" "404"
reqj PUT "/api/public/groups/$GROUP2" "$TOKEN" "{\"name\":\"Hijacked\"}"
check_eq "updating a group of another org -> 404" "$HTTP_CODE" "404"
req DELETE "/api/public/groups/$GROUP2" "$TOKEN"
check_eq "deleting a group of another org -> 404" "$HTTP_CODE" "404"
echo ""
echo "== Delete =="
req DELETE "/api/public/members/$NEWMEMBER" "$TOKEN"
check_eq "delete member -> 200" "$HTTP_CODE" "200"
req GET "/api/public/members/$NEWMEMBER" "$TOKEN"
check_eq "deleted member is gone -> 404" "$HTTP_CODE" "404"
req DELETE "/api/public/groups/$NEWGROUP" "$TOKEN"
check_eq "delete group -> 200" "$HTTP_CODE" "200"
req GET "/api/public/groups/$NEWGROUP" "$TOKEN"
check_eq "deleted group is gone -> 404" "$HTTP_CODE" "404"
echo ""
echo "== Authentication required =="
reqj POST "/api/public/groups" "" "{\"name\":\"NoToken\"}"
check_eq "create group with no token -> 401" "$HTTP_CODE" "401"
req DELETE "/api/public/members/$MEMBER3"
check_eq "delete member with no token -> 401" "$HTTP_CODE" "401"
req GET "/api/public/members/$MEMBER3" "$TOKEN"
check_eq "the unauthenticated delete changed nothing" "$HTTP_CODE" "200"
echo ""
echo "== Writes are recorded in the event log without an acting user =="
stop_server
# 1400 GroupCreated, 1401 GroupUpdated, 1402 GroupDeleted,
# 1500 OrganizationUserInvited, 1502 OrganizationUserUpdated,
# 1503 OrganizationUserRemoved, 1511 Revoked, 1512 Restored.
for pair in "1400:group created" "1401:group updated" "1402:group deleted" \
"1500:member invited" "1502:member updated" "1503:member removed" \
"1511:member revoked" "1512:member restored"; do
code="${pair%%:*}"
label="${pair#*:}"
got=$(sqlite3 "$TMP/db.sqlite3" \
"SELECT COUNT(*) > 0 FROM event WHERE org_uuid='$ORG' AND event_type=$code;")
check_eq "event logged: $label" "$got" "1"
done
sqlcheck "no Public API event records an acting user" \
"SELECT COUNT(*) FROM event WHERE org_uuid='$ORG' AND act_user_uuid IS NOT NULL;" "0"
sqlcheck "no Public API event records a device type" \
"SELECT COUNT(*) FROM event WHERE org_uuid='$ORG' AND device_type IS NOT NULL;" "0"
sqlcheck "Public API events still record the client address" \
"SELECT COUNT(*) FROM event WHERE org_uuid='$ORG' AND ip_address IS NULL;" "0"
echo ""
if [ "$FAILS" -ne 0 ]; then
echo "RESULT: $FAILS assertion(s) failed."
exit 1
fi
echo "RESULT: all assertions passed."

28
src/api/core/events.rs

@ -190,8 +190,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
event.r#type,
org_id,
org_id,
&headers.user.uuid,
headers.device.atype,
Some(&headers.user.uuid),
Some(headers.device.atype),
Some(event_date),
&headers.ip.ip,
&conn,
@ -211,8 +211,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
event.r#type,
cipher_uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
Some(&headers.user.uuid),
Some(headers.device.atype),
Some(event_date),
&headers.ip.ip,
&conn,
@ -278,7 +278,17 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() {
return;
}
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
log_event_impl(event_type, source_uuid, org_id, Some(act_user_id), Some(device_type), None, ip, conn).await;
}
/// Log an organization event that was triggered through the Public API.
/// These are performed by an organization API client instead of a user, so there is
/// no acting user or device to record and both are stored as null.
pub async fn log_public_event(event_type: i32, source_uuid: &str, org_id: &OrganizationId, ip: &IpAddr, conn: &DbConn) {
if !CONFIG.org_events_enabled() {
return;
}
log_event_impl(event_type, source_uuid, org_id, None, None, None, ip, conn).await;
}
#[expect(clippy::too_many_arguments)]
@ -286,8 +296,8 @@ async fn log_event_impl(
event_type: i32,
source_uuid: &str,
org_id: &OrganizationId,
act_user_id: &UserId,
device_type: i32,
act_user_id: Option<&UserId>,
device_type: Option<i32>,
event_date: Option<NaiveDateTime>,
ip: &IpAddr,
conn: &DbConn,
@ -322,8 +332,8 @@ async fn log_event_impl(
}
event.org_uuid = Some(org_id.clone());
event.act_user_uuid = Some(act_user_id.clone());
event.device_type = Some(device_type);
event.act_user_uuid = act_user_id.cloned();
event.device_type = device_type;
event.ip_address = Some(ip.to_string());
event.save(conn).await.unwrap_or(());
}

885
src/api/core/public.rs

@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use chrono::Utc;
use rocket::{
@ -6,23 +6,49 @@ use rocket::{
request::{FromRequest, Outcome},
serde::json::Json,
};
use serde_json::Value;
use crate::{
CONFIG,
api::EmptyResult,
api::{EmptyResult, JsonResult, Notify, UpdateType},
auth,
db::{
DbConn,
models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
Collection, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser,
Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization,
OrganizationApiKey, OrganizationId, User,
},
},
mail,
util::NumberOrString,
};
use super::events::log_public_event;
pub fn routes() -> Vec<Route> {
routes![ldap_import]
routes![
ldap_import,
get_members,
get_member,
get_member_group_ids,
get_groups,
get_group,
get_group_member_ids,
get_collections,
get_collection,
post_member,
put_member,
delete_member,
put_member_group_ids,
post_member_reinvite,
post_member_revoke,
post_member_restore,
post_group,
put_group,
delete_group,
put_group_member_ids,
]
}
#[derive(Deserialize)]
@ -196,6 +222,857 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
Ok(())
}
// These endpoints implement the read side of the organization Public API so an
// organization-scoped API client can read back the members, groups, and
// collections (and their associations) that the existing
// "/public/organization/import" endpoint writes. They all reuse the PublicToken
// guard, so they are authorized by the same organization API key.
// Base member object. The list endpoint returns this as-is; the single-member
// endpoint extends it with "collections".
async fn member_to_json(member: &Membership, conn: &DbConn) -> Value {
let (name, email) = match User::find_by_uuid(&member.user_uuid, conn).await {
Some(user) => {
let name = if user.name.is_empty() {
Value::Null
} else {
Value::String(user.name)
};
(name, Value::String(user.email))
}
None => (Value::Null, Value::Null),
};
// Revoked members carry their pre-revocation status offset by ACTIVATE_REVOKE_DIFF so
// it can be restored later. Upstream only knows -1, which is what the other serializers
// report too, so clamp it here rather than leaking the internal encoding.
let status = if member.status < MembershipStatus::Revoked as i32 {
MembershipStatus::Revoked as i32
} else {
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": membership_type,
"externalId": member.external_id,
"resetPasswordEnrolled": member.reset_password_key.is_some(),
"status": status,
"permissions": permissions,
})
}
// Base group object. The list endpoint returns this as-is; the single-group
// endpoint extends it with "collections".
fn group_to_json(group: &Group) -> Value {
json!({
"object": "group",
"id": group.uuid,
"name": group.name,
"accessAll": group.access_all,
"externalId": group.external_id,
})
}
// Base collection object. The Bitwarden Public API keys collections by id and
// externalId only; the name is deliberately omitted because it is end-to-end
// encrypted ciphertext. The single-collection endpoint extends it with "groups".
fn collection_to_json(collection: &Collection) -> Value {
json!({
"object": "collection",
"id": collection.uuid,
"externalId": collection.external_id,
})
}
#[get("/public/members")]
async fn get_members(token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
let mut members_json = Vec::new();
for member in Membership::find_by_org(&org_id, &conn).await {
members_json.push(member_to_json(&member, &conn).await);
}
Ok(Json(json!({
"object": "list",
"data": members_json,
"continuationToken": null,
})))
}
#[get("/public/members/<member_id>")]
async fn get_member(member_id: MembershipId, token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
let collections: Vec<Value> = CollectionUser::find_by_organization_and_user_uuid(&org_id, &member.user_uuid, &conn)
.await
.iter()
.map(|c| {
json!({
"id": c.collection_uuid,
"readOnly": c.read_only,
"hidePasswords": c.hide_passwords,
"manage": c.manage,
})
})
.collect();
let mut member_json = member_to_json(&member, &conn).await;
member_json["collections"] = json!(collections);
Ok(Json(member_json))
}
#[get("/public/members/<member_id>/group-ids")]
async fn get_member_group_ids(member_id: MembershipId, token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() {
err_code!(format!("Member {member_id} not found in organization"), 404);
}
// GroupUser links a group to a membership, so a member's group ids are the
// group uuids of the GroupUser rows referencing this membership.
let group_ids: Vec<GroupId> =
GroupUser::find_by_member(&member_id, &conn).await.into_iter().map(|gu| gu.groups_uuid).collect();
Ok(Json(json!(group_ids)))
}
#[get("/public/groups")]
async fn get_groups(token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
let groups_json: Vec<Value> = Group::find_by_organization(&org_id, &conn).await.iter().map(group_to_json).collect();
Ok(Json(json!({
"object": "list",
"data": groups_json,
"continuationToken": null,
})))
}
#[get("/public/groups/<group_id>")]
async fn get_group(group_id: GroupId, token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else {
err_code!(format!("Group {group_id} not found in organization"), 404);
};
let collections: Vec<Value> = CollectionGroup::find_by_group(&group_id, &org_id, &conn)
.await
.iter()
.map(|c| {
json!({
"id": c.collections_uuid,
"readOnly": c.read_only,
"hidePasswords": c.hide_passwords,
"manage": c.manage,
})
})
.collect();
let mut group_json = group_to_json(&group);
group_json["collections"] = json!(collections);
Ok(Json(group_json))
}
#[get("/public/groups/<group_id>/member-ids")]
async fn get_group_member_ids(group_id: GroupId, token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err_code!(format!("Group {group_id} not found in organization"), 404);
}
// A group's member ids are the membership uuids of its GroupUser rows.
let member_ids: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &org_id, &conn)
.await
.into_iter()
.map(|gu| gu.users_organizations_uuid)
.collect();
Ok(Json(json!(member_ids)))
}
#[get("/public/collections")]
async fn get_collections(token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
let collections_json: Vec<Value> =
Collection::find_by_organization(&org_id, &conn).await.iter().map(collection_to_json).collect();
Ok(Json(json!({
"object": "list",
"data": collections_json,
"continuationToken": null,
})))
}
#[get("/public/collections/<collection_id>")]
async fn get_collection(collection_id: CollectionId, token: PublicToken, conn: DbConn) -> JsonResult {
let org_id = token.0;
let Some(collection) = Collection::find_by_uuid_and_org(&collection_id, &org_id, &conn).await else {
err_code!(format!("Collection {collection_id} not found in organization"), 404);
};
let groups: Vec<Value> = CollectionGroup::find_by_collection(&collection_id, &conn)
.await
.iter()
.map(|c| {
json!({
"id": c.groups_uuid,
"readOnly": c.read_only,
"hidePasswords": c.hide_passwords,
"manage": c.manage,
})
})
.collect();
let mut collection_json = collection_to_json(&collection);
collection_json["groups"] = json!(groups);
Ok(Json(collection_json))
}
// These endpoints implement the write side of the organization Public API. Together
// with the read endpoints above they replace the need to drive every change through
// "/public/organization/import", which is a full directory snapshot and revokes any
// member missing from the payload when overwriteExisting is set.
//
// The write paths mirror the equivalent internal endpoints in `organizations.rs`, but
// are guarded by PublicToken instead of AdminHeaders. A PublicToken carries only an
// organization, so the per-actor permission checks of the internal API do not apply;
// the guards that protect organization integrity (last confirmed owner, org policies,
// group support being enabled) are kept.
// Bitwarden models an assignment to a collection as its id plus the permissions granted.
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AssociationData {
id: CollectionId,
#[serde(default)]
read_only: bool,
#[serde(default)]
hide_passwords: bool,
#[serde(default)]
manage: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MemberCreateData {
email: String,
r#type: NumberOrString,
external_id: Option<String>,
#[serde(default)]
collections: Vec<AssociationData>,
#[serde(default)]
groups: Vec<GroupId>,
// Our own read shape emits null here for anyone who is not a manage-all member, and a
// client is expected to send a read straight back, so null has to deserialize.
#[serde(default)]
permissions: Option<HashMap<String, Value>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MemberUpdateData {
r#type: NumberOrString,
external_id: Option<String>,
// An omitted collections list clears the assignments, but an omitted groups list
// leaves them alone, matching how upstream treats the two.
#[serde(default)]
collections: Vec<AssociationData>,
groups: Option<Vec<GroupId>>,
// Our own read shape emits null here for anyone who is not a manage-all member, and a
// client is expected to send a read straight back, so null has to deserialize.
#[serde(default)]
permissions: Option<HashMap<String, Value>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GroupCreateUpdateData {
name: String,
// Upstream dropped accessAll from its group model, but the Vaultwarden group still
// carries the flag. It is accepted here so it stays reachable, and left untouched
// when omitted so a client following the upstream model cannot silently clear it.
access_all: Option<bool>,
external_id: Option<String>,
#[serde(default)]
collections: Vec<AssociationData>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GroupIdsData {
group_ids: Vec<GroupId>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MemberIdsData {
member_ids: Vec<MembershipId>,
}
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type
fn member_type_and_access_all(
r#type: NumberOrString,
permissions: &HashMap<String, Value>,
) -> Option<(MembershipType, bool)> {
let raw_type = &r#type.into_string();
// MembershipType::from_str will convert custom (4) to manager (3)
let new_type = MembershipType::from_str(raw_type)?;
// HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag
// Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes
// If the box is not checked, the user will still be a manager, but not with the access_all permission
let access_all = new_type >= MembershipType::Admin
|| (raw_type.eq("4")
&& permissions.get("editAnyCollection") == Some(&json!(true))
&& permissions.get("deleteAnyCollection") == Some(&json!(true))
&& permissions.get("createNewCollections") == Some(&json!(true)));
Some((new_type, access_all))
}
// The internal endpoints only let an Owner grant, change or remove Owner. A Public API
// client has no user behind it to check that against, and the organization API key can be
// created by an Admin, so ownership is placed out of its reach entirely.
fn deny_owner_grant(new_type: MembershipType) -> EmptyResult {
if new_type == MembershipType::Owner {
err!("The Public API cannot grant the Owner role")
}
Ok(())
}
fn deny_owner_target(member: &Membership) -> EmptyResult {
if member.atype == MembershipType::Owner {
err!("The Public API cannot modify an organization owner")
}
Ok(())
}
async fn validate_collections(collections: &[AssociationData], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
}
Ok(())
}
async fn validate_groups(group_ids: &[GroupId], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(e) = group_ids.iter().find(|g| !org_group_ids.contains(g)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
}
Ok(())
}
async fn validate_members(member_ids: &[MembershipId], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = member_ids.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
Ok(())
}
// Replace a member's collection assignments. Members of type Admin or Owner reach every
// collection through their type, so no explicit assignments are stored for them.
async fn set_member_collections(
member: &Membership,
collections: &[AssociationData],
org_id: &OrganizationId,
conn: &DbConn,
) -> EmptyResult {
for c in CollectionUser::find_by_organization_and_user_uuid(org_id, &member.user_uuid, conn).await {
c.delete(conn).await?;
}
if !member.access_all {
for col in collections {
CollectionUser::save(&member.user_uuid, &col.id, col.read_only, col.hide_passwords, col.manage, conn)
.await?;
}
}
Ok(())
}
async fn set_member_groups(member: &Membership, group_ids: &[GroupId], conn: &DbConn) -> EmptyResult {
GroupUser::delete_all_by_member(&member.uuid, conn).await?;
for group_id in group_ids {
let mut group_entry = GroupUser::new(group_id.clone(), member.uuid.clone());
group_entry.save(conn).await?;
}
Ok(())
}
async fn set_group_collections(
group: &Group,
collections: &[AssociationData],
org_id: &OrganizationId,
conn: &DbConn,
) -> EmptyResult {
CollectionGroup::delete_all_by_group(&group.uuid, org_id, conn).await?;
for col in collections {
let mut collection_group =
CollectionGroup::new(col.id.clone(), group.uuid.clone(), col.read_only, col.hide_passwords, col.manage);
collection_group.save(org_id, conn).await?;
}
Ok(())
}
// A Public API client is not a member of the organization, so invites are recorded as
// coming from the organization itself, the same way "/public/organization/import" does.
async fn org_name_and_email(org_id: &OrganizationId, conn: &DbConn) -> Result<(String, String), crate::error::Error> {
let Some(org) = Organization::find_by_uuid(org_id, conn).await else {
err!("Error looking up organization")
};
Ok((org.name, org.billing_email))
}
#[post("/public/members", data = "<data>")]
async fn post_member(data: Json<MemberCreateData>, token: PublicToken, ip: auth::ClientIp, conn: DbConn) -> JsonResult {
let org_id = token.0;
let data = data.into_inner();
let permissions = data.permissions.unwrap_or_default();
let Some((new_type, access_all)) = member_type_and_access_all(data.r#type, &permissions) else {
err!("Invalid type")
};
deny_owner_grant(new_type)?;
validate_collections(&data.collections, &org_id, &conn).await?;
validate_groups(&data.groups, &org_id, &conn).await?;
let mut user_created = false;
let mut member_status = MembershipStatus::Invited as i32;
let user = match User::find_by_mail(&data.email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {}", data.email))
}
if !CONFIG.is_email_domain_allowed(&data.email) {
err!("Email domain not eligible for invitations")
}
if !CONFIG.mail_enabled() {
Invitation::new(&data.email).save(&conn).await?;
}
let mut new_user = User::new(&data.email, None);
new_user.save(&conn).await?;
user_created = true;
new_user
}
Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
err!(format!("User already in organization: {}", data.email))
}
if !CONFIG.mail_enabled() {
if user.password_hash.is_empty() {
Invitation::new(&data.email).save(&conn).await?;
} else {
// automatically accept existing users if mail is disabled
member_status = MembershipStatus::Accepted as i32;
}
}
user
}
};
let (org_name, org_email) = org_name_and_email(&org_id, &conn).await?;
let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
new_member.access_all = access_all;
new_member.atype = new_type as i32;
new_member.status = member_status;
// Only touch the external id when one was sent. Clearing it on omission would break
// the key "/public/organization/import" matches members and groups on.
if data.external_id.is_some() {
new_member.set_external_id(data.external_id.clone());
}
new_member.save(&conn).await?;
if CONFIG.mail_enabled()
&& let Err(e) =
mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await
{
// Upon error delete the user, invite and org member records when needed
if user_created {
user.delete(&conn).await?;
} else {
new_member.delete(&conn).await?;
}
err!(format!("Error sending invite: {e:?} "));
}
log_public_event(EventType::OrganizationUserInvited as i32, &new_member.uuid, &org_id, &ip.ip, &conn).await;
set_member_collections(&new_member, &data.collections, &org_id, &conn).await?;
set_member_groups(&new_member, &data.groups, &conn).await?;
Ok(Json(member_to_json(&new_member, &conn).await))
}
#[put("/public/members/<member_id>", data = "<data>")]
async fn put_member(
member_id: MembershipId,
data: Json<MemberUpdateData>,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> JsonResult {
let org_id = token.0;
let data = data.into_inner();
let permissions = data.permissions.unwrap_or_default();
let Some((new_type, access_all)) = member_type_and_access_all(data.r#type, &permissions) else {
err!("Invalid type")
};
deny_owner_grant(new_type)?;
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
deny_owner_target(&member)?;
validate_collections(&data.collections, &org_id, &conn).await?;
if let Some(group_ids) = &data.groups {
validate_groups(group_ids, &org_id, &conn).await?;
}
member.access_all = access_all;
member.atype = new_type as i32;
if data.external_id.is_some() {
member.set_external_id(data.external_id.clone());
}
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member,
// admin::update_membership_type. We need to perform the check after changing the type.
OrgPolicy::check_user_allowed(&member, "modify", &conn).await?;
set_member_collections(&member, &data.collections, &org_id, &conn).await?;
if let Some(group_ids) = &data.groups {
set_member_groups(&member, group_ids, &conn).await?;
}
member.save(&conn).await?;
log_public_event(EventType::OrganizationUserUpdated as i32, &member.uuid, &org_id, &ip.ip, &conn).await;
Ok(Json(member_to_json(&member, &conn).await))
}
#[delete("/public/members/<member_id>")]
async fn delete_member(
member_id: MembershipId,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let org_id = token.0;
let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
deny_owner_target(&member)?;
log_public_event(EventType::OrganizationUserRemoved as i32, &member.uuid, &org_id, &ip.ip, &conn).await;
if let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await {
// There is no device behind a Public API request, so no push device to exclude.
nt.send_user_update(UpdateType::SyncOrgKeys, &user, None, &conn).await;
if !CONFIG.mail_enabled()
&& !Membership::find_invited_by_user(&user.uuid, &conn).await.into_iter().any(|m| m.uuid != member.uuid)
{
Invitation::take(&user.email, &conn).await;
}
}
member.delete(&conn).await
}
#[put("/public/members/<member_id>/group-ids", data = "<data>")]
async fn put_member_group_ids(
member_id: MembershipId,
data: Json<GroupIdsData>,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> EmptyResult {
let org_id = token.0;
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
let data = data.into_inner();
validate_groups(&data.group_ids, &org_id, &conn).await?;
set_member_groups(&member, &data.group_ids, &conn).await?;
log_public_event(EventType::OrganizationUserUpdatedGroups as i32, &member.uuid, &org_id, &ip.ip, &conn).await;
Ok(())
}
#[post("/public/members/<member_id>/reinvite")]
async fn post_member_reinvite(member_id: MembershipId, token: PublicToken, conn: DbConn) -> EmptyResult {
let org_id = token.0;
let Some(member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
deny_owner_target(&member)?;
if member.status != MembershipStatus::Invited as i32 {
err!("The user is already accepted or confirmed to the organization")
}
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found.")
};
if !CONFIG.invitations_allowed() && user.password_hash.is_empty() {
err!("Invitations are not allowed.")
}
let (org_name, org_email) = org_name_and_email(&org_id, &conn).await?;
if CONFIG.mail_enabled() {
mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(org_email)).await?;
} else if user.password_hash.is_empty() {
Invitation::new(&user.email).save(&conn).await?;
} else {
Invitation::take(&user.email, &conn).await;
let mut member = member;
member.status = MembershipStatus::Accepted as i32;
member.save(&conn).await?;
}
Ok(())
}
#[post("/public/members/<member_id>/revoke")]
async fn post_member_revoke(
member_id: MembershipId,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> EmptyResult {
let org_id = token.0;
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
deny_owner_target(&member)?;
if member.status <= MembershipStatus::Revoked as i32 {
err!("User is already revoked")
}
member.revoke();
member.save(&conn).await?;
log_public_event(EventType::OrganizationUserRevoked as i32, &member.uuid, &org_id, &ip.ip, &conn).await;
Ok(())
}
#[post("/public/members/<member_id>/restore")]
async fn post_member_restore(
member_id: MembershipId,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> EmptyResult {
let org_id = token.0;
let Some(mut member) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
err_code!(format!("Member {member_id} not found in organization"), 404);
};
deny_owner_target(&member)?;
// Anything above Revoked is already active. Testing against Accepted would let an
// invited member through, producing a no-op save and a restore event that never happened.
if member.status > MembershipStatus::Revoked as i32 {
err!("User is already active")
}
member.restore();
// This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member,
// admin::update_membership_type. It needs to happen after restoring to see the correct status.
OrgPolicy::check_user_allowed(&member, "restore", &conn).await?;
member.save(&conn).await?;
log_public_event(EventType::OrganizationUserRestored as i32, &member.uuid, &org_id, &ip.ip, &conn).await;
Ok(())
}
#[post("/public/groups", data = "<data>")]
async fn post_group(
data: Json<GroupCreateUpdateData>,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> JsonResult {
let org_id = token.0;
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
let data = data.into_inner();
validate_collections(&data.collections, &org_id, &conn).await?;
let mut group =
Group::new(org_id.clone(), data.name.clone(), data.access_all.unwrap_or(false), data.external_id.clone());
group.save(&conn).await?;
log_public_event(EventType::GroupCreated as i32, &group.uuid, &org_id, &ip.ip, &conn).await;
set_group_collections(&group, &data.collections, &org_id, &conn).await?;
Ok(Json(group_to_json(&group)))
}
#[put("/public/groups/<group_id>", data = "<data>")]
async fn put_group(
group_id: GroupId,
data: Json<GroupCreateUpdateData>,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> JsonResult {
let org_id = token.0;
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
let Some(mut group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else {
err_code!(format!("Group {group_id} not found in organization"), 404);
};
let data = data.into_inner();
validate_collections(&data.collections, &org_id, &conn).await?;
group.name.clone_from(&data.name);
if let Some(access_all) = data.access_all {
group.access_all = access_all;
}
// Unlike the internal endpoint, the external_id is updatable here. The Public API is
// the directory integration surface, the same one "/public/organization/import" uses
// to assign external ids in the first place.
if data.external_id.is_some() {
group.set_external_id(data.external_id.clone());
}
group.save(&conn).await?;
// Member assignments are owned by "/public/groups/<group_id>/member-ids" and are
// deliberately left untouched here.
log_public_event(EventType::GroupUpdated as i32, &group.uuid, &org_id, &ip.ip, &conn).await;
set_group_collections(&group, &data.collections, &org_id, &conn).await?;
Ok(Json(group_to_json(&group)))
}
#[delete("/public/groups/<group_id>")]
async fn delete_group(group_id: GroupId, token: PublicToken, ip: auth::ClientIp, conn: DbConn) -> EmptyResult {
let org_id = token.0;
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
let Some(group) = Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await else {
err_code!(format!("Group {group_id} not found in organization"), 404);
};
log_public_event(EventType::GroupDeleted as i32, &group.uuid, &org_id, &ip.ip, &conn).await;
group.delete(&org_id, &conn).await
}
#[put("/public/groups/<group_id>/member-ids", data = "<data>")]
async fn put_group_member_ids(
group_id: GroupId,
data: Json<MemberIdsData>,
token: PublicToken,
ip: auth::ClientIp,
conn: DbConn,
) -> EmptyResult {
let org_id = token.0;
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err_code!(format!("Group {group_id} not found in organization"), 404);
}
let data = data.into_inner();
validate_members(&data.member_ids, &org_id, &conn).await?;
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for member_id in &data.member_ids {
let mut user_entry = GroupUser::new(group_id.clone(), member_id.clone());
user_entry.save(&conn).await?;
log_public_event(EventType::OrganizationUserUpdatedGroups as i32, member_id, &org_id, &ip.ip, &conn).await;
}
Ok(())
}
pub struct PublicToken(OrganizationId);
#[rocket::async_trait]

Loading…
Cancel
Save