diff --git a/scripts/smoke_public_api_events.sh b/scripts/smoke_public_api_events.sh new file mode 100755 index 00000000..5f39c4a2 --- /dev/null +++ b/scripts/smoke_public_api_events.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# +# Smoke test for the organization Public API event log endpoint. +# +# Boots a throwaway Vaultwarden instance against a temporary SQLite database +# seeded with two organizations and a handful of events, mints an organization +# API token for the first org, then exercises GET /public/events. +# +# It covers the date range filter, the newest-first ordering, the page size and +# continuation token, the organization scoping boundary, rejection of malformed +# dates, and the empty response returned when org events are disabled. +# +# 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_events.sh +# VW_BIN=/path/to/vaultwarden PORT=8123 scripts/smoke_public_api_events.sh + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +cd "$REPO_ROOT" + +PORT="${PORT:-8081}" +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 +MEMBER=33333333-3333-4333-8333-333333333333 +GROUP=44444444-4444-4444-8444-444444444444 +APIKEYUUID=77777777-7777-4777-8777-777777777777 +APIKEY=smoketestapikey1234567890 + +# Whole range that covers every seeded event, and a narrow one that does not. +FULL_START=2026-01-01T00:00:00Z +FULL_END=2026-12-31T23:59:59Z +NARROW_START=2026-03-02T12:00:00Z + +# ---- 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_EVENTS_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 +} + +mint_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' +} + +# ---- 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 +} + +check_ne() { # label actual not_expected + if [ "$2" != "$3" ]; then + pass "$1" + else + fail "$1 (did not expect [$3])" + 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" +} + +events_url() { # start end [continuationToken] + local url="/api/public/events?start=$1&end=$2" + if [ -n "${3:-}" ]; then + url="$url&continuationToken=$3" + fi + printf '%s' "$url" +} + +# ---- 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 and their events ==" +sqlite3 "$TMP/db.sqlite3" <&2 + exit 1 +fi +pass "minted organization API token" + +echo "" +echo "== Event list ==" + +req GET "$(events_url "$FULL_START" "$FULL_END")" "$TOKEN" +check_eq "events -> 200" "$HTTP_CODE" "200" +jqcheck "events is a list object" '.object' "list" +jqcheck "events returns only this organization's events" '.data | length' "3" +jqcheck "short page has no continuationToken" '.continuationToken' "null" + +# Ordered newest first, so the most recent seeded event comes back first. +jqcheck "newest event first" '.data[0].type' "1500" +jqcheck "newest event organizationId" '.data[0].organizationId' "$ORG" +jqcheck "newest event organizationUserId" '.data[0].organizationUserId' "$MEMBER" +jqcheck "newest event actingUserId" '.data[0].actingUserId' "$USER" +jqcheck "oldest event last" '.data[2].type' "1600" + +# An event with no acting user must still be returned, with a null actingUserId. +jqcheck "actor-less event is returned" '.data[1].type' "1400" +jqcheck "actor-less event has null actingUserId" '.data[1].actingUserId' "null" +jqcheck "actor-less event keeps its groupId" '.data[1].groupId' "$GROUP" +jqcheck "actor-less event has null deviceType" '.data[1].deviceType' "null" + +echo "" +echo "== Date range filter ==" + +req GET "$(events_url "$NARROW_START" "$FULL_END")" "$TOKEN" +check_eq "narrowed range -> 200" "$HTTP_CODE" "200" +jqcheck "narrowed range drops older events" '.data | length' "1" +jqcheck "narrowed range keeps the newest event" '.data[0].type' "1500" + +echo "" +echo "== Malformed dates are rejected, not fatal ==" + +for bad in "start=notadate&end=$FULL_END" "start=$FULL_START&end=notadate"; do + req GET "/api/public/events?$bad" "$TOKEN" + check_ne "malformed date does not fault the server ($bad)" "$HTTP_CODE" "500" + check_eq "malformed date is a client error ($bad)" "$HTTP_CODE" "400" +done + +req GET "$(events_url "$FULL_START" "$FULL_END" notadate)" "$TOKEN" +check_ne "malformed continuationToken does not fault the server" "$HTTP_CODE" "500" +check_eq "malformed continuationToken is a client error" "$HTTP_CODE" "400" + +# The server must still be serving after all of that. +req GET "/alive" +check_eq "server still serving after malformed input" "$HTTP_CODE" "200" + +echo "" +echo "== Page size and continuation token ==" + +# Add enough events to overflow a single page. PAGE_SIZE is 30. +sqlite3 "$TMP/db.sqlite3" < 200" "$HTTP_CODE" "200" +jqcheck "full page is capped at the page size" '.data | length' "30" +check_ne "full page exposes a continuationToken" "$(jqval '.continuationToken')" "null" +jqcheck "continuationToken is the date of the last event on the page" \ + '.continuationToken == (.data[-1].date)' "true" + +# Feeding the token back must walk further back in time, not repeat the page. +NEXT=$(jqval '.continuationToken') +FIRST_DATE=$(jqval '.data[0].date') +req GET "$(events_url "$FULL_START" "$FULL_END" "$NEXT")" "$TOKEN" +check_eq "paged request -> 200" "$HTTP_CODE" "200" +check_ne "second page starts after the first" "$(jqval '.data[0].date')" "$FIRST_DATE" + +echo "" +echo "== Organization scoping boundary ==" + +req GET "$(events_url "$FULL_START" "$FULL_END")" "$TOKEN" +jqcheck "no event belongs to another organization" \ + "[.data[] | select(.organizationId != \"$ORG\")] | length" "0" + +echo "" +echo "== Authentication required ==" + +req GET "$(events_url "$FULL_START" "$FULL_END")" +check_eq "no token -> 401" "$HTTP_CODE" "401" + +req GET "$(events_url "$FULL_START" "$FULL_END")" "not-a-real-token" +check_eq "bogus token -> 401" "$HTTP_CODE" "401" + +echo "" +echo "== Events disabled returns an empty list ==" + +stop_server +export ORG_EVENTS_ENABLED=false +start_server "$TMP/boot3.log" + +TOKEN=$(mint_token) +if [ -z "$TOKEN" ]; then + echo "FAIL: could not mint an organization API token after restart" >&2 + exit 1 +fi + +req GET "$(events_url "$FULL_START" "$FULL_END")" "$TOKEN" +check_eq "events disabled -> 200" "$HTTP_CODE" "200" +jqcheck "events disabled returns an empty list" '.data | length' "0" +jqcheck "events disabled still returns a list object" '.object' "list" + +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/events.rs b/src/api/core/events.rs index 5518fa3c..d3d8cc84 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -22,11 +22,11 @@ pub fn routes() -> Vec { } #[derive(FromForm)] -struct EventRange { - start: String, - end: String, +pub struct EventRange { + pub start: String, + pub end: String, #[field(name = "continuationToken")] - continuation_token: Option, + pub continuation_token: Option, } // Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87 @@ -125,7 +125,7 @@ async fn get_user_events( }))) } -fn get_continuation_token(events_json: &[Value]) -> Option<&str> { +pub fn get_continuation_token(events_json: &[Value]) -> Option<&str> { // When the length of the vec equals the max page_size there probably is more data // When it is less, then all events are loaded. #[expect(clippy::cast_possible_truncation, reason = "PAGE_SIZE fits within usize")] diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 3db25df9..a815c76c 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -6,23 +6,27 @@ 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, OrgPolicy, Organization, + Event, Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization, OrganizationApiKey, OrganizationId, User, }, }, mail, + util::parse_date_checked, }; +use super::events::{EventRange, get_continuation_token}; + pub fn routes() -> Vec { - routes![ldap_import] + routes![ldap_import, get_events] } #[derive(Deserialize)] @@ -196,6 +200,43 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn Ok(()) } +// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Public/Controllers/EventsController.cs +// Exposes the organization event log to an organization-scoped API client. The +// same data is otherwise only reachable through the internal API, which requires +// an admin user session instead of an organization API key. +#[get("/public/events?")] +async fn get_events(data: EventRange, token: PublicToken, conn: DbConn) -> JsonResult { + let org_id = token.0; + + // Return an empty vec when the org events are disabled. + // This prevents client errors + let events_json: Vec = if CONFIG.org_events_enabled() { + // These come straight from the query string, so they must not be parsed with + // parse_date(), which panics on anything that is not a valid RFC 3339 date. + let Some(start_date) = parse_date_checked(&data.start) else { + err!("Invalid start date") + }; + let end = data.continuation_token.as_deref().unwrap_or(&data.end); + let Some(end_date) = parse_date_checked(end) else { + err!("Invalid end date") + }; + + Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn) + .await + .iter() + .map(Event::to_json) + .collect() + } else { + Vec::new() + }; + + Ok(Json(json!({ + "object": "list", + "data": events_json, + "continuationToken": get_continuation_token(&events_json), + }))) +} + pub struct PublicToken(OrganizationId); #[rocket::async_trait] diff --git a/src/util.rs b/src/util.rs index 0e8a93e4..1e328b26 100644 --- a/src/util.rs +++ b/src/util.rs @@ -523,7 +523,13 @@ pub fn format_datetime_http(dt: &DateTime) -> String { } pub fn parse_date(date: &str) -> NaiveDateTime { - DateTime::parse_from_rfc3339(date).unwrap().naive_utc() + parse_date_checked(date).unwrap() +} + +/// Parses an RFC 3339 date, returning None instead of panicking when the input is not a +/// valid date. Use this for dates that come straight from a request. +pub fn parse_date_checked(date: &str) -> Option { + DateTime::parse_from_rfc3339(date).ok().map(|d| d.naive_utc()) } /// Returns true or false if an email address is valid or not