Browse Source

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/<id> (with collection grants)
- GET /public/members/<id>/group-ids
- GET /public/groups and /public/groups/<id> (with collection grants)
- GET /public/groups/<id>/member-ids
- GET /public/collections and /public/collections/<id> (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 <noreply@anthropic.com>
pull/7414/head
Rune Darrud 2 weeks ago
parent
commit
14497da8a0
  1. 227
      src/api/core/public.rs

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