From 14497da8a0c1fed208d58b9e00cc9244a66f2c73 Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Sun, 12 Jul 2026 15:13:58 +0300 Subject: [PATCH 1/2] Add Public API read endpoints for members, groups, and collections The public organization API previously exposed only the write-side "/public/organization/import" endpoint. This adds the corresponding read endpoints so an organization-scoped API client can read back the members, groups, and collections it manages, along with their access associations: - GET /public/members and /public/members/ (with collection grants) - GET /public/members//group-ids - GET /public/groups and /public/groups/ (with collection grants) - GET /public/groups//member-ids - GET /public/collections and /public/collections/ (with group grants) All handlers reuse the existing PublicToken guard, so they are authorized by the same organization API key as the import endpoint, and every handler is scoped to the token's organization: a resource id that belongs to another organization returns 404 rather than leaking data. Collection responses intentionally omit the end-to-end encrypted name and key collections by id and externalId only. Co-Authored-By: Claude Opus 4.8 --- src/api/core/public.rs | 227 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 223 insertions(+), 4 deletions(-) diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 33189e78..92a4b9bc 100644 --- a/src/api/core/public.rs +++ b/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 { - 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, 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/")] +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 = 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//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 = + 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 = 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/")] +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 = 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//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 = 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 = + 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/")] +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 = 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] From 65971461177c8e88a17ff3b059c2f36bd3bfc143 Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Sun, 12 Jul 2026 15:27:16 +0300 Subject: [PATCH 2/2] Add smoke test for the organization Public API read endpoints Boots a throwaway instance against a temporary SQLite database seeded with two organizations (each with members, groups, collections and their access associations), mints an organization API token, and asserts on every read endpoint: member/group/collection lists and details, the direct member-to-collection grant on member detail, the group-to-collection grant on group and collection detail, and that collection responses omit the encrypted name. It also asserts the organization scoping boundary (ids owned by the second organization return 404 through the first org's token) and that an unauthenticated request returns 401. The script exits non-zero if any assertion fails, so it can be run as a check. It builds the binary when one is not supplied via VW_BIN, uses a throwaway port and temp directory, and cleans up on exit. Co-Authored-By: Claude Opus 4.8 --- scripts/smoke_public_api.sh | 310 ++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100755 scripts/smoke_public_api.sh diff --git a/scripts/smoke_public_api.sh b/scripts/smoke_public_api.sh new file mode 100755 index 00000000..4f541a25 --- /dev/null +++ b/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" <&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."