From 0f8d565258eaf7b9ffc5c9e64c895c6af88656f2 Mon Sep 17 00:00:00 2001 From: Rune Darrud Date: Mon, 10 Aug 2026 00:37:04 +0200 Subject: [PATCH] Add Public API member and group write endpoints Adds the write side of the organization Public API for members and groups, so an organization API client can make incremental changes instead of pushing a full directory snapshot through /public/organization/import, which revokes any member missing from the payload when overwriteExisting is set. Members: create (invite), update, delete, replace group ids, reinvite, revoke and restore. Groups: create, update, delete and replace member ids. Routes and request models follow the upstream Bitwarden Public API controllers. The handlers mirror the equivalent internal endpoints in organizations.rs but are guarded by PublicToken instead of AdminHeaders. A PublicToken carries only an organization, so the per-actor permission checks do not apply, while the guards protecting organization integrity are kept: the last confirmed owner cannot be demoted, revoked or deleted, org policies are still enforced on member changes, and group endpoints still require group support to be enabled. Public API writes have no acting user or device, so log_event_impl now takes both as optional and a new log_public_event records these events with a null actingUserId rather than leaving them out of the organization event log. Co-Authored-By: Claude Opus 5 --- src/api/core/events.rs | 28 +- src/api/core/public.rs | 603 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 617 insertions(+), 14 deletions(-) diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 5518fa3c..d2b19d13 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -190,8 +190,8 @@ async fn post_events_collect(data: Json>, headers: Headers, event.r#type, org_id, org_id, - &headers.user.uuid, - headers.device.atype, + Some(&headers.user.uuid), + Some(headers.device.atype), Some(event_date), &headers.ip.ip, &conn, @@ -211,8 +211,8 @@ async fn post_events_collect(data: Json>, headers: Headers, event.r#type, cipher_uuid, &org_id, - &headers.user.uuid, - headers.device.atype, + Some(&headers.user.uuid), + Some(headers.device.atype), Some(event_date), &headers.ip.ip, &conn, @@ -278,7 +278,17 @@ pub async fn log_event( if !CONFIG.org_events_enabled() { return; } - log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await; + log_event_impl(event_type, source_uuid, org_id, Some(act_user_id), Some(device_type), None, ip, conn).await; +} + +/// Log an organization event that was triggered through the Public API. +/// These are performed by an organization API client instead of a user, so there is +/// no acting user or device to record and both are stored as null. +pub async fn log_public_event(event_type: i32, source_uuid: &str, org_id: &OrganizationId, ip: &IpAddr, conn: &DbConn) { + if !CONFIG.org_events_enabled() { + return; + } + log_event_impl(event_type, source_uuid, org_id, None, None, None, ip, conn).await; } #[expect(clippy::too_many_arguments)] @@ -286,8 +296,8 @@ async fn log_event_impl( event_type: i32, source_uuid: &str, org_id: &OrganizationId, - act_user_id: &UserId, - device_type: i32, + act_user_id: Option<&UserId>, + device_type: Option, event_date: Option, ip: &IpAddr, conn: &DbConn, @@ -322,8 +332,8 @@ async fn log_event_impl( } event.org_uuid = Some(org_id.clone()); - event.act_user_uuid = Some(act_user_id.clone()); - event.device_type = Some(device_type); + event.act_user_uuid = act_user_id.cloned(); + event.device_type = device_type; event.ip_address = Some(ip.to_string()); event.save(conn).await.unwrap_or(()); } diff --git a/src/api/core/public.rs b/src/api/core/public.rs index 7d9bd05a..b6f091f1 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use chrono::Utc; use rocket::{ @@ -10,19 +10,22 @@ use serde_json::Value; use crate::{ CONFIG, - api::{EmptyResult, JsonResult}, + api::{EmptyResult, JsonResult, Notify, UpdateType}, auth, db::{ DbConn, models::{ - Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, Invitation, - Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization, OrganizationApiKey, - OrganizationId, User, + Collection, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, + Invitation, Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, Organization, + OrganizationApiKey, OrganizationId, User, }, }, mail, + util::NumberOrString, }; +use super::events::log_public_event; + pub fn routes() -> Vec { routes![ ldap_import, @@ -34,6 +37,17 @@ pub fn routes() -> Vec { get_group_member_ids, get_collections, get_collection, + post_member, + put_member, + delete_member, + put_member_group_ids, + post_member_reinvite, + post_member_revoke, + post_member_restore, + post_group, + put_group, + delete_group, + put_group_member_ids, ] } @@ -415,6 +429,585 @@ async fn get_collection(collection_id: CollectionId, token: PublicToken, conn: D Ok(Json(collection_json)) } +// These endpoints implement the write side of the organization Public API. Together +// with the read endpoints above they replace the need to drive every change through +// "/public/organization/import", which is a full directory snapshot and revokes any +// member missing from the payload when overwriteExisting is set. +// +// The write paths mirror the equivalent internal endpoints in `organizations.rs`, but +// are guarded by PublicToken instead of AdminHeaders. A PublicToken carries only an +// organization, so the per-actor permission checks of the internal API do not apply; +// the guards that protect organization integrity (last confirmed owner, org policies, +// group support being enabled) are kept. + +// Bitwarden models an assignment to a collection as its id plus the permissions granted. +// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Public/Models/AssociationWithPermissionsBaseModel.cs +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssociationData { + id: CollectionId, + #[serde(default)] + read_only: bool, + #[serde(default)] + hide_passwords: bool, + #[serde(default)] + manage: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MemberCreateData { + email: String, + r#type: NumberOrString, + external_id: Option, + #[serde(default)] + collections: Vec, + #[serde(default)] + groups: Vec, + #[serde(default)] + permissions: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MemberUpdateData { + r#type: NumberOrString, + external_id: Option, + #[serde(default)] + collections: Vec, + #[serde(default)] + groups: Vec, + #[serde(default)] + permissions: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GroupCreateUpdateData { + name: String, + // Upstream dropped accessAll from its group model, but the Vaultwarden group still + // carries the flag, so it is accepted here and defaults to false when omitted. + #[serde(default)] + access_all: bool, + external_id: Option, + #[serde(default)] + collections: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GroupIdsData { + group_ids: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MemberIdsData { + member_ids: Vec, +} + +// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission +// The from_str() will convert the custom role type into a manager role type +fn member_type_and_access_all( + r#type: NumberOrString, + permissions: &HashMap, +) -> Option<(MembershipType, bool)> { + let raw_type = &r#type.into_string(); + // MembershipType::from_str will convert custom (4) to manager (3) + let new_type = MembershipType::from_str(raw_type)?; + + // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag + // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes + // If the box is not checked, the user will still be a manager, but not with the access_all permission + let access_all = new_type >= MembershipType::Admin + || (raw_type.eq("4") + && permissions.get("editAnyCollection") == Some(&json!(true)) + && permissions.get("deleteAnyCollection") == Some(&json!(true)) + && permissions.get("createNewCollections") == Some(&json!(true))); + + Some((new_type, access_all)) +} + +async fn validate_collections(collections: &[AssociationData], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + let org_collections = Collection::find_by_organization(org_id, conn).await; + let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); + if let Some(e) = collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { + err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) + } + Ok(()) +} + +async fn validate_groups(group_ids: &[GroupId], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + let org_groups = Group::find_by_organization(org_id, conn).await; + let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); + if let Some(e) = group_ids.iter().find(|g| !org_group_ids.contains(g)) { + err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id)) + } + Ok(()) +} + +async fn validate_members(member_ids: &[MembershipId], org_id: &OrganizationId, conn: &DbConn) -> EmptyResult { + let org_memberships = Membership::find_by_org(org_id, conn).await; + let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); + if let Some(e) = member_ids.iter().find(|m| !org_membership_ids.contains(m)) { + err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) + } + Ok(()) +} + +// Replace a member's collection assignments. Members of type Admin or Owner reach every +// collection through their type, so no explicit assignments are stored for them. +async fn set_member_collections( + member: &Membership, + collections: &[AssociationData], + org_id: &OrganizationId, + conn: &DbConn, +) -> EmptyResult { + for c in CollectionUser::find_by_organization_and_user_uuid(org_id, &member.user_uuid, conn).await { + c.delete(conn).await?; + } + + if !member.access_all { + for col in collections { + CollectionUser::save(&member.user_uuid, &col.id, col.read_only, col.hide_passwords, col.manage, conn) + .await?; + } + } + + Ok(()) +} + +async fn set_member_groups(member: &Membership, group_ids: &[GroupId], conn: &DbConn) -> EmptyResult { + GroupUser::delete_all_by_member(&member.uuid, conn).await?; + for group_id in group_ids { + let mut group_entry = GroupUser::new(group_id.clone(), member.uuid.clone()); + group_entry.save(conn).await?; + } + + Ok(()) +} + +async fn set_group_collections( + group: &Group, + collections: &[AssociationData], + org_id: &OrganizationId, + conn: &DbConn, +) -> EmptyResult { + CollectionGroup::delete_all_by_group(&group.uuid, org_id, conn).await?; + for col in collections { + let mut collection_group = + CollectionGroup::new(col.id.clone(), group.uuid.clone(), col.read_only, col.hide_passwords, col.manage); + collection_group.save(org_id, conn).await?; + } + + Ok(()) +} + +// A Public API client is not a member of the organization, so invites are recorded as +// coming from the organization itself, the same way "/public/organization/import" does. +async fn org_name_and_email(org_id: &OrganizationId, conn: &DbConn) -> Result<(String, String), crate::error::Error> { + let Some(org) = Organization::find_by_uuid(org_id, conn).await else { + err!("Error looking up organization") + }; + + Ok((org.name, org.billing_email)) +} + +#[post("/public/members", data = "")] +async fn post_member(data: Json, token: PublicToken, ip: auth::ClientIp, conn: DbConn) -> JsonResult { + let org_id = token.0; + let data = data.into_inner(); + + let Some((new_type, access_all)) = member_type_and_access_all(data.r#type, &data.permissions) else { + err!("Invalid type") + }; + + validate_collections(&data.collections, &org_id, &conn).await?; + validate_groups(&data.groups, &org_id, &conn).await?; + + let mut user_created = false; + let mut member_status = MembershipStatus::Invited as i32; + let user = match User::find_by_mail(&data.email, &conn).await { + None => { + if !CONFIG.invitations_allowed() { + err!(format!("User does not exist: {}", data.email)) + } + + if !CONFIG.is_email_domain_allowed(&data.email) { + err!("Email domain not eligible for invitations") + } + + if !CONFIG.mail_enabled() { + Invitation::new(&data.email).save(&conn).await?; + } + + let mut new_user = User::new(&data.email, None); + new_user.save(&conn).await?; + user_created = true; + new_user + } + Some(user) => { + if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { + err!(format!("User already in organization: {}", data.email)) + } + + if !CONFIG.mail_enabled() { + if user.password_hash.is_empty() { + Invitation::new(&data.email).save(&conn).await?; + } else { + // automatically accept existing users if mail is disabled + member_status = MembershipStatus::Accepted as i32; + } + } + user + } + }; + + let (org_name, org_email) = org_name_and_email(&org_id, &conn).await?; + + let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone())); + new_member.access_all = access_all; + new_member.atype = new_type as i32; + new_member.status = member_status; + new_member.set_external_id(data.external_id.clone()); + new_member.save(&conn).await?; + + if CONFIG.mail_enabled() + && let Err(e) = + mail::send_invite(&user, org_id.clone(), new_member.uuid.clone(), &org_name, Some(org_email)).await + { + // Upon error delete the user, invite and org member records when needed + if user_created { + user.delete(&conn).await?; + } else { + new_member.delete(&conn).await?; + } + + err!(format!("Error sending invite: {e:?} ")); + } + + log_public_event(EventType::OrganizationUserInvited as i32, &new_member.uuid, &org_id, &ip.ip, &conn).await; + + set_member_collections(&new_member, &data.collections, &org_id, &conn).await?; + set_member_groups(&new_member, &data.groups, &conn).await?; + + Ok(Json(member_to_json(&new_member, &conn).await)) +} + +#[put("/public/members/", data = "")] +async fn put_member( + member_id: MembershipId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> JsonResult { + let org_id = token.0; + let data = data.into_inner(); + + let Some((new_type, access_all)) = member_type_and_access_all(data.r#type, &data.permissions) else { + err!("Invalid type") + }; + + let Some(mut 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); + }; + + validate_collections(&data.collections, &org_id, &conn).await?; + validate_groups(&data.groups, &org_id, &conn).await?; + + if member.atype == MembershipType::Owner + && new_type != MembershipType::Owner + && member.status == MembershipStatus::Confirmed as i32 + { + // Removing owner permission, check that there is at least one other confirmed owner + if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { + err!("Can't delete the last owner") + } + } + + member.access_all = access_all; + member.atype = new_type as i32; + member.set_external_id(data.external_id.clone()); + + // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, + // admin::update_membership_type. We need to perform the check after changing the type. + OrgPolicy::check_user_allowed(&member, "modify", &conn).await?; + + set_member_collections(&member, &data.collections, &org_id, &conn).await?; + set_member_groups(&member, &data.groups, &conn).await?; + + member.save(&conn).await?; + + log_public_event(EventType::OrganizationUserUpdated as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(Json(member_to_json(&member, &conn).await)) +} + +#[delete("/public/members/")] +async fn delete_member( + member_id: MembershipId, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + 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); + }; + + if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 { + // Removing owner, check that there is at least one other confirmed owner + if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 { + err!("Can't delete the last owner") + } + } + + log_public_event(EventType::OrganizationUserRemoved as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + if let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await { + // There is no device behind a Public API request, so no push device to exclude. + nt.send_user_update(UpdateType::SyncOrgKeys, &user, None, &conn).await; + + if !CONFIG.mail_enabled() + && !Membership::find_invited_by_user(&user.uuid, &conn).await.into_iter().any(|m| m.uuid != member.uuid) + { + Invitation::take(&user.email, &conn).await; + } + } + + member.delete(&conn).await +} + +#[put("/public/members//group-ids", data = "")] +async fn put_member_group_ids( + member_id: MembershipId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + 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 data = data.into_inner(); + validate_groups(&data.group_ids, &org_id, &conn).await?; + + set_member_groups(&member, &data.group_ids, &conn).await?; + + log_public_event(EventType::OrganizationUserUpdatedGroups as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(()) +} + +#[post("/public/members//reinvite")] +async fn post_member_reinvite(member_id: MembershipId, token: PublicToken, conn: DbConn) -> EmptyResult { + 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); + }; + + if member.status != MembershipStatus::Invited as i32 { + err!("The user is already accepted or confirmed to the organization") + } + + let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { + err!("User not found.") + }; + + if !CONFIG.invitations_allowed() && user.password_hash.is_empty() { + err!("Invitations are not allowed.") + } + + let (org_name, org_email) = org_name_and_email(&org_id, &conn).await?; + + if CONFIG.mail_enabled() { + mail::send_invite(&user, org_id.clone(), member.uuid, &org_name, Some(org_email)).await?; + } else if user.password_hash.is_empty() { + Invitation::new(&user.email).save(&conn).await?; + } else { + Invitation::take(&user.email, &conn).await; + let mut member = member; + member.status = MembershipStatus::Accepted as i32; + member.save(&conn).await?; + } + + Ok(()) +} + +#[post("/public/members//revoke")] +async fn post_member_revoke( + member_id: MembershipId, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + let Some(mut 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); + }; + + if member.status <= MembershipStatus::Revoked as i32 { + err!("User is already revoked") + } + + if member.atype == MembershipType::Owner + && Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 + { + err!("Organization must have at least one confirmed owner") + } + + member.revoke(); + member.save(&conn).await?; + + log_public_event(EventType::OrganizationUserRevoked as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(()) +} + +#[post("/public/members//restore")] +async fn post_member_restore( + member_id: MembershipId, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + let Some(mut 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); + }; + + if member.status >= MembershipStatus::Accepted as i32 { + err!("User is already active") + } + + member.restore(); + // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, + // admin::update_membership_type. It needs to happen after restoring to see the correct status. + OrgPolicy::check_user_allowed(&member, "restore", &conn).await?; + member.save(&conn).await?; + + log_public_event(EventType::OrganizationUserRestored as i32, &member.uuid, &org_id, &ip.ip, &conn).await; + + Ok(()) +} + +#[post("/public/groups", data = "")] +async fn post_group( + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> JsonResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + let data = data.into_inner(); + validate_collections(&data.collections, &org_id, &conn).await?; + + let mut group = Group::new(org_id.clone(), data.name.clone(), data.access_all, data.external_id.clone()); + group.save(&conn).await?; + + set_group_collections(&group, &data.collections, &org_id, &conn).await?; + + log_public_event(EventType::GroupCreated as i32, &group.uuid, &org_id, &ip.ip, &conn).await; + + Ok(Json(group_to_json(&group))) +} + +#[put("/public/groups/", data = "")] +async fn put_group( + group_id: GroupId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> JsonResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + let Some(mut 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 data = data.into_inner(); + validate_collections(&data.collections, &org_id, &conn).await?; + + group.name.clone_from(&data.name); + group.access_all = data.access_all; + // Unlike the internal endpoint, the external_id is updatable here. The Public API is + // the directory integration surface, the same one "/public/organization/import" uses + // to assign external ids in the first place. + group.set_external_id(data.external_id.clone()); + group.save(&conn).await?; + + // Member assignments are owned by "/public/groups//member-ids" and are + // deliberately left untouched here. + set_group_collections(&group, &data.collections, &org_id, &conn).await?; + + log_public_event(EventType::GroupUpdated as i32, &group.uuid, &org_id, &ip.ip, &conn).await; + + Ok(Json(group_to_json(&group))) +} + +#[delete("/public/groups/")] +async fn delete_group(group_id: GroupId, token: PublicToken, ip: auth::ClientIp, conn: DbConn) -> EmptyResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + 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); + }; + + log_public_event(EventType::GroupDeleted as i32, &group.uuid, &org_id, &ip.ip, &conn).await; + + group.delete(&org_id, &conn).await +} + +#[put("/public/groups//member-ids", data = "")] +async fn put_group_member_ids( + group_id: GroupId, + data: Json, + token: PublicToken, + ip: auth::ClientIp, + conn: DbConn, +) -> EmptyResult { + let org_id = token.0; + if !CONFIG.org_groups_enabled() { + err!("Group support is disabled"); + } + + 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); + } + + let data = data.into_inner(); + validate_members(&data.member_ids, &org_id, &conn).await?; + + GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?; + for member_id in &data.member_ids { + let mut user_entry = GroupUser::new(group_id.clone(), member_id.clone()); + user_entry.save(&conn).await?; + + log_public_event(EventType::OrganizationUserUpdatedGroups as i32, member_id, &org_id, &ip.ip, &conn).await; + } + + Ok(()) +} + pub struct PublicToken(OrganizationId); #[rocket::async_trait]