From 26850247fad2b9a3dde631217081f8db0f97b9c0 Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Mon, 10 Aug 2026 00:26:34 +0200 Subject: [PATCH 1/3] Add Public API organization event log endpoint Expose GET /public/events so an organization-scoped API client can read the organization event log using its organization API key. The same data is otherwise only reachable through the internal API, which requires an admin user session. Reuses the existing EventRange query model and continuation-token paging helper from the internal events endpoints, so the request and response shapes match /organizations//events. Co-Authored-By: Claude Opus 5 --- src/api/core/events.rs | 10 +++++----- src/api/core/public.rs | 44 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) 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..f8fd1c2a 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, }; +use super::events::{EventRange, get_continuation_token}; + pub fn routes() -> Vec { - routes![ldap_import] + routes![ldap_import, get_events] } #[derive(Deserialize)] @@ -196,6 +200,40 @@ 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() { + let start_date = parse_date(&data.start); + let end_date = if let Some(before_date) = &data.continuation_token { + parse_date(before_date) + } else { + parse_date(&data.end) + }; + + 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] From bc491e983089397df3e0364791d71dd7f7c7187a Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Mon, 10 Aug 2026 01:01:47 +0200 Subject: [PATCH 2/3] Do not panic on a malformed Public API event date parse_date() unwraps the RFC 3339 parse, so a malformed start, end or continuationToken in a /public/events request panicked the handler and returned a 500. These values come straight from the query string. Adds parse_date_checked(), which returns None instead of panicking, and uses it for the three client supplied dates so an invalid one is reported as a normal error response. parse_date() keeps its previous behaviour for existing callers. Co-Authored-By: Claude Opus 5 --- src/api/core/public.rs | 15 +++++++++------ src/util.rs | 8 +++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/api/core/public.rs b/src/api/core/public.rs index f8fd1c2a..a815c76c 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -20,7 +20,7 @@ use crate::{ }, }, mail, - util::parse_date, + util::parse_date_checked, }; use super::events::{EventRange, get_continuation_token}; @@ -211,11 +211,14 @@ async fn get_events(data: EventRange, token: PublicToken, conn: DbConn) -> JsonR // Return an empty vec when the org events are disabled. // This prevents client errors let events_json: Vec = if CONFIG.org_events_enabled() { - let start_date = parse_date(&data.start); - let end_date = if let Some(before_date) = &data.continuation_token { - parse_date(before_date) - } else { - parse_date(&data.end) + // 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) diff --git a/src/util.rs b/src/util.rs index 91f075d1..d6ceb202 100644 --- a/src/util.rs +++ b/src/util.rs @@ -485,7 +485,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 From 2a2925b3c1e85c6227b88afbb031f37b47261010 Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Mon, 10 Aug 2026 01:17:10 +0200 Subject: [PATCH 3/3] Add smoke test for the Public API event log endpoint Boots a throwaway instance against a seeded SQLite database and exercises GET /public/events end to end: the date range filter, newest-first ordering, the page size cap and continuation token, the organization scoping boundary, authentication, and the empty list returned when org events are disabled. Also covers malformed start, end and continuationToken values, which returned a 500 before parse_date_checked was introduced. Co-Authored-By: Claude Opus 5 --- scripts/smoke_public_api_events.sh | 332 +++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100755 scripts/smoke_public_api_events.sh 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."