@ -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 , Organization Id , User ,
} ,
} ,
mail ,
util ::NumberOrString ,
} ;
use super ::events ::log_public_event ;
pub fn routes ( ) -> Vec < Route > {
routes ! [
ldap_import ,
@ -34,6 +37,17 @@ pub fn routes() -> Vec<Route> {
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 < String > ,
#[ serde(default) ]
collections : Vec < AssociationData > ,
#[ serde(default) ]
groups : Vec < GroupId > ,
#[ serde(default) ]
permissions : HashMap < String , Value > ,
}
#[ derive(Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct MemberUpdateData {
r#type : NumberOrString ,
external_id : Option < String > ,
#[ serde(default) ]
collections : Vec < AssociationData > ,
#[ serde(default) ]
groups : Vec < GroupId > ,
#[ serde(default) ]
permissions : HashMap < String , Value > ,
}
#[ 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 < String > ,
#[ serde(default) ]
collections : Vec < AssociationData > ,
}
#[ derive(Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct GroupIdsData {
group_ids : Vec < GroupId > ,
}
#[ derive(Deserialize) ]
#[ serde(rename_all = " camelCase " ) ]
struct MemberIdsData {
member_ids : Vec < MembershipId > ,
}
// 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 < String , Value > ,
) -> 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 = " <data> " ) ]
async fn post_member ( data : Json < MemberCreateData > , 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/<member_id> " , data = " <data> " ) ]
async fn put_member (
member_id : MembershipId ,
data : Json < MemberUpdateData > ,
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/<member_id> " ) ]
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/<member_id>/group-ids " , data = " <data> " ) ]
async fn put_member_group_ids (
member_id : MembershipId ,
data : Json < GroupIdsData > ,
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/<member_id>/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/<member_id>/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/<member_id>/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 = " <data> " ) ]
async fn post_group (
data : Json < GroupCreateUpdateData > ,
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/<group_id> " , data = " <data> " ) ]
async fn put_group (
group_id : GroupId ,
data : Json < GroupCreateUpdateData > ,
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/<group_id>/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/<group_id> " ) ]
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/<group_id>/member-ids " , data = " <data> " ) ]
async fn put_group_member_ids (
group_id : GroupId ,
data : Json < MemberIdsData > ,
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 ]