Browse Source

Merge 6597146117 into 4a9bcb0694

pull/7414/merge
Rune Darrud 1 day ago
committed by GitHub
parent
commit
cb07bf8395
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 310
      scripts/smoke_public_api.sh
  2. 227
      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."

227
src/api/core/public.rs

@ -6,23 +6,35 @@ use rocket::{
request::{FromRequest, Outcome},
serde::json::Json,
};
use serde_json::Value;
use crate::{
CONFIG,
api::EmptyResult,
api::{EmptyResult, JsonResult},
auth,
db::{
DbConn,
models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
OrganizationApiKey, OrganizationId, User,
Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, Invitation,
Membership, MembershipId, MembershipStatus, MembershipType, Organization, OrganizationApiKey,
OrganizationId, User,
},
},
mail,
};
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,
]
}
#[derive(Deserialize)]
@ -189,6 +201,213 @@ 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),
};
json!({
"object": "member",
"id": member.uuid,
"userId": member.user_uuid,
"name": name,
"email": email,
"type": member.atype,
"externalId": member.external_id,
"resetPasswordEnrolled": member.reset_password_key.is_some(),
"status": member.status,
})
}
// 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))
}
pub struct PublicToken(OrganizationId);
#[rocket::async_trait]

Loading…
Cancel
Save