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." 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]