1
0
mirror of https://github.com/dani-garcia/vaultwarden.git synced 2024-11-25 04:13:32 +01:00

group support

This commit is contained in:
MFijak 2022-10-20 15:31:53 +02:00
parent 4cb5122e90
commit 21bc3bfd53
17 changed files with 1188 additions and 19 deletions

View File

@ -0,0 +1,3 @@
DROP TABLE `groups`;
DROP TABLE groups_users;
DROP TABLE collections_groups;

View File

@ -0,0 +1,23 @@
CREATE TABLE `groups` (
uuid CHAR(36) NOT NULL PRIMARY KEY,
organizations_uuid VARCHAR(40) NOT NULL REFERENCES organizations (uuid),
name VARCHAR(100) NOT NULL,
access_all BOOLEAN NOT NULL,
external_id VARCHAR(300) NULL,
creation_date DATETIME NOT NULL,
revision_date DATETIME NOT NULL
);
CREATE TABLE groups_users (
groups_uuid CHAR(36) NOT NULL REFERENCES `groups` (uuid),
users_organizations_uuid VARCHAR(36) NOT NULL REFERENCES users_organizations (uuid),
UNIQUE (groups_uuid, users_organizations_uuid)
);
CREATE TABLE collections_groups (
collections_uuid VARCHAR(40) NOT NULL REFERENCES collections (uuid),
groups_uuid CHAR(36) NOT NULL REFERENCES `groups` (uuid),
read_only BOOLEAN NOT NULL,
hide_passwords BOOLEAN NOT NULL,
UNIQUE (collections_uuid, groups_uuid)
);

View File

@ -0,0 +1,3 @@
DROP TABLE groups;
DROP TABLE groups_users;
DROP TABLE collections_groups;

View File

@ -0,0 +1,23 @@
CREATE TABLE groups (
uuid CHAR(36) NOT NULL PRIMARY KEY,
organizations_uuid VARCHAR(40) NOT NULL REFERENCES organizations (uuid),
name VARCHAR(100) NOT NULL,
access_all BOOLEAN NOT NULL,
external_id VARCHAR(300) NULL,
creation_date TIMESTAMP NOT NULL,
revision_date TIMESTAMP NOT NULL
);
CREATE TABLE groups_users (
groups_uuid CHAR(36) NOT NULL REFERENCES groups (uuid),
users_organizations_uuid VARCHAR(36) NOT NULL REFERENCES users_organizations (uuid),
PRIMARY KEY (groups_uuid, users_organizations_uuid)
);
CREATE TABLE collections_groups (
collections_uuid VARCHAR(40) NOT NULL REFERENCES collections (uuid),
groups_uuid CHAR(36) NOT NULL REFERENCES groups (uuid),
read_only BOOLEAN NOT NULL,
hide_passwords BOOLEAN NOT NULL,
PRIMARY KEY (collections_uuid, groups_uuid)
);

View File

@ -0,0 +1,3 @@
DROP TABLE groups;
DROP TABLE groups_users;
DROP TABLE collections_groups;

View File

@ -0,0 +1,23 @@
CREATE TABLE groups (
uuid TEXT NOT NULL PRIMARY KEY,
organizations_uuid TEXT NOT NULL REFERENCES organizations (uuid),
name TEXT NOT NULL,
access_all BOOLEAN NOT NULL,
external_id TEXT NULL,
creation_date TIMESTAMP NOT NULL,
revision_date TIMESTAMP NOT NULL
);
CREATE TABLE groups_users (
groups_uuid TEXT NOT NULL REFERENCES groups (uuid),
users_organizations_uuid TEXT NOT NULL REFERENCES users_organizations (uuid),
UNIQUE (groups_uuid, users_organizations_uuid)
);
CREATE TABLE collections_groups (
collections_uuid TEXT NOT NULL REFERENCES collections (uuid),
groups_uuid TEXT NOT NULL REFERENCES groups (uuid),
read_only BOOLEAN NOT NULL,
hide_passwords BOOLEAN NOT NULL,
UNIQUE (collections_uuid, groups_uuid)
);

View File

@ -1499,6 +1499,8 @@ pub struct CipherSyncData {
pub cipher_collections: HashMap<String, Vec<String>>,
pub user_organizations: HashMap<String, UserOrganization>,
pub user_collections: HashMap<String, CollectionUser>,
pub user_collections_groups: HashMap<String, CollectionGroup>,
pub user_group_full_access_for_organizations: HashSet<String>,
}
pub enum CipherSyncType {
@ -1554,6 +1556,16 @@ impl CipherSyncData {
.collect()
.await;
// Generate a HashMap with the collections_uuid as key and the CollectionGroup record
let user_collections_groups = stream::iter(CollectionGroup::find_by_user(user_uuid, conn).await)
.map(|collection_group| (collection_group.collections_uuid.clone(), collection_group))
.collect()
.await;
// Get all organizations that the user has full access to via group assignement
let user_group_full_access_for_organizations =
stream::iter(Group::gather_user_organizations_full_access(user_uuid, conn).await).collect().await;
Self {
cipher_attachments,
cipher_folders,
@ -1561,6 +1573,8 @@ impl CipherSyncData {
cipher_collections,
user_organizations,
user_collections,
user_collections_groups,
user_group_full_access_for_organizations,
}
}
}

View File

@ -6,7 +6,8 @@ use serde_json::Value;
use crate::{
api::{
core::{CipherSyncData, CipherSyncType},
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType,
ApiResult, EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, JsonVec, Notify, NumberOrString, PasswordData,
UpdateType,
},
auth::{decode_invite, AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OwnerHeaders},
db::{models::*, DbConn},
@ -71,6 +72,21 @@ pub fn routes() -> Vec<Route> {
bulk_activate_organization_user,
restore_organization_user,
bulk_restore_organization_user,
get_groups,
post_groups,
get_group,
put_group,
post_group,
get_group_details,
delete_group,
post_delete_group,
get_group_users,
put_group_users,
get_user_groups,
post_user_groups,
put_user_groups,
delete_group_user,
post_delete_group_user,
get_org_export
]
}
@ -94,10 +110,19 @@ struct OrganizationUpdateData {
Name: String,
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct NewCollectionData {
Name: String,
Groups: Vec<NewCollectionGroupData>,
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct NewCollectionGroupData {
HidePasswords: bool,
Id: String,
ReadOnly: bool,
}
#[derive(Deserialize)]
@ -287,6 +312,12 @@ async fn post_organization_collections(
let collection = Collection::new(org.uuid, data.Name);
collection.save(&conn).await?;
for group in data.Groups {
CollectionGroup::new(collection.uuid.clone(), group.Id, group.ReadOnly, group.HidePasswords)
.save(&conn)
.await?;
}
// If the user doesn't have access to all collections, only in case of a Manger,
// then we need to save the creating user uuid (Manager) to the users_collection table.
// Else the user will not have access to his own created collection.
@ -335,6 +366,12 @@ async fn post_organization_collection_update(
collection.name = data.Name;
collection.save(&conn).await?;
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?;
for group in data.Groups {
CollectionGroup::new(col_id.clone(), group.Id, group.ReadOnly, group.HidePasswords).save(&conn).await?;
}
Ok(Json(collection.to_json()))
}
@ -430,7 +467,19 @@ async fn get_org_collection_detail(
err!("Collection is not owned by organization")
}
Ok(Json(collection.to_json()))
let groups: Vec<Value> = CollectionGroup::find_by_collection(&collection.uuid, &conn)
.await
.iter()
.map(|collection_group| {
SelectionReadOnly::to_collection_group_details_read_only(collection_group).to_json()
})
.collect();
let mut json_object = collection.to_json();
json_object["Groups"] = json!(groups);
json_object["Object"] = json!("collectionGroupDetails");
Ok(Json(json_object))
}
}
}
@ -1704,6 +1753,324 @@ async fn _restore_organization_user(
Ok(())
}
#[get("/organizations/<org_id>/groups")]
async fn get_groups(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let groups = Group::find_by_organization(&org_id, &conn).await.iter().map(Group::to_json).collect::<Value>();
Ok(Json(json!({
"Data": groups,
"Object": "list",
"ContinuationToken": null,
})))
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct GroupRequest {
Name: String,
AccessAll: Option<bool>,
ExternalId: Option<String>,
Collections: Vec<SelectionReadOnly>,
}
impl GroupRequest {
pub fn to_group(&self, organizations_uuid: &str) -> ApiResult<Group> {
match self.AccessAll {
Some(access_all_value) => Ok(Group::new(
organizations_uuid.to_owned(),
self.Name.clone(),
access_all_value,
self.ExternalId.clone(),
)),
_ => err!("Could not convert GroupRequest to Group, because AccessAll has no value!"),
}
}
pub fn update_group(&self, mut group: Group) -> ApiResult<Group> {
match self.AccessAll {
Some(access_all_value) => {
group.name = self.Name.clone();
group.access_all = access_all_value;
group.set_external_id(self.ExternalId.clone());
Ok(group)
}
_ => err!("Could not update group, because AccessAll has no value!"),
}
}
}
#[derive(Deserialize, Serialize)]
#[allow(non_snake_case)]
struct SelectionReadOnly {
Id: String,
ReadOnly: bool,
HidePasswords: bool,
}
impl SelectionReadOnly {
pub fn to_collection_group(&self, groups_uuid: String) -> CollectionGroup {
CollectionGroup::new(self.Id.clone(), groups_uuid, self.ReadOnly, self.HidePasswords)
}
pub fn to_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly {
SelectionReadOnly {
Id: collection_group.collections_uuid.clone(),
ReadOnly: collection_group.read_only,
HidePasswords: collection_group.hide_passwords,
}
}
pub fn to_collection_group_details_read_only(collection_group: &CollectionGroup) -> SelectionReadOnly {
SelectionReadOnly {
Id: collection_group.groups_uuid.clone(),
ReadOnly: collection_group.read_only,
HidePasswords: collection_group.hide_passwords,
}
}
pub fn to_json(&self) -> Value {
json!(self)
}
}
#[post("/organizations/<_org_id>/groups/<group_id>", data = "<data>")]
async fn post_group(
_org_id: String,
group_id: String,
data: JsonUpcase<GroupRequest>,
_headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_group(_org_id, group_id, data, _headers, conn).await
}
#[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: String,
_headers: AdminHeaders,
data: JsonUpcase<GroupRequest>,
conn: DbConn,
) -> JsonResult {
let group_request = data.into_inner().data;
let group = group_request.to_group(&org_id)?;
add_update_group(group, group_request.Collections, &conn).await
}
#[put("/organizations/<_org_id>/groups/<group_id>", data = "<data>")]
async fn put_group(
_org_id: String,
group_id: String,
data: JsonUpcase<GroupRequest>,
_headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
let group = match Group::find_by_uuid(&group_id, &conn).await {
Some(group) => group,
None => err!("Group not found"),
};
let group_request = data.into_inner().data;
let updated_group = group_request.update_group(group)?;
CollectionGroup::delete_all_by_group(&group_id, &conn).await?;
add_update_group(updated_group, group_request.Collections, &conn).await
}
async fn add_update_group(mut group: Group, collections: Vec<SelectionReadOnly>, conn: &DbConn) -> JsonResult {
group.save(conn).await?;
for selection_read_only_request in collections {
let mut collection_group = selection_read_only_request.to_collection_group(group.uuid.clone());
collection_group.save(conn).await?;
}
Ok(Json(json!({
"Id": group.uuid,
"OrganizationId": group.organizations_uuid,
"Name": group.name,
"AccessAll": group.access_all,
"ExternalId": group.get_external_id()
})))
}
#[get("/organizations/<_org_id>/groups/<group_id>/details")]
async fn get_group_details(_org_id: String, group_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let group = match Group::find_by_uuid(&group_id, &conn).await {
Some(group) => group,
_ => err!("Group could not be found!"),
};
let collections_groups = CollectionGroup::find_by_group(&group_id, &conn)
.await
.iter()
.map(|entry| SelectionReadOnly::to_group_details_read_only(entry).to_json())
.collect::<Value>();
Ok(Json(json!({
"Id": group.uuid,
"OrganizationId": group.organizations_uuid,
"Name": group.name,
"AccessAll": group.access_all,
"ExternalId": group.get_external_id(),
"Collections": collections_groups
})))
}
#[post("/organizations/<org_id>/groups/<group_id>/delete")]
async fn post_delete_group(org_id: String, group_id: String, _headers: AdminHeaders, conn: DbConn) -> EmptyResult {
delete_group(org_id, group_id, _headers, conn).await
}
#[delete("/organizations/<_org_id>/groups/<group_id>")]
async fn delete_group(_org_id: String, group_id: String, _headers: AdminHeaders, conn: DbConn) -> EmptyResult {
let group = match Group::find_by_uuid(&group_id, &conn).await {
Some(group) => group,
_ => err!("Group not found"),
};
group.delete(&conn).await
}
#[get("/organizations/<_org_id>/groups/<group_id>")]
async fn get_group(_org_id: String, group_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
let group = match Group::find_by_uuid(&group_id, &conn).await {
Some(group) => group,
_ => err!("Group not found"),
};
Ok(Json(group.to_json()))
}
#[get("/organizations/<_org_id>/groups/<group_id>/users")]
async fn get_group_users(_org_id: String, group_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
match Group::find_by_uuid(&group_id, &conn).await {
Some(_) => { /* Do nothing */ }
_ => err!("Group could not be found!"),
};
let group_users: Vec<String> = GroupUser::find_by_group(&group_id, &conn)
.await
.iter()
.map(|entry| entry.users_organizations_uuid.clone())
.collect();
Ok(Json(json!(group_users)))
}
#[put("/organizations/<_org_id>/groups/<group_id>/users", data = "<data>")]
async fn put_group_users(
_org_id: String,
group_id: String,
_headers: AdminHeaders,
data: JsonVec<String>,
conn: DbConn,
) -> EmptyResult {
match Group::find_by_uuid(&group_id, &conn).await {
Some(_) => { /* Do nothing */ }
_ => err!("Group could not be found!"),
};
GroupUser::delete_all_by_group(&group_id, &conn).await?;
let assigned_user_ids = data.into_inner();
for assigned_user_id in assigned_user_ids {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_user_id);
user_entry.save(&conn).await?;
}
Ok(())
}
#[get("/organizations/<_org_id>/users/<user_id>/groups")]
async fn get_user_groups(_org_id: String, user_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonResult {
match UserOrganization::find_by_uuid(&user_id, &conn).await {
Some(_) => { /* Do nothing */ }
_ => err!("User could not be found!"),
};
let user_groups: Vec<String> =
GroupUser::find_by_user(&user_id, &conn).await.iter().map(|entry| entry.groups_uuid.clone()).collect();
Ok(Json(json!(user_groups)))
}
#[derive(Deserialize)]
#[allow(non_snake_case)]
struct OrganizationUserUpdateGroupsRequest {
GroupIds: Vec<String>,
}
#[post("/organizations/<_org_id>/users/<user_id>/groups", data = "<data>")]
async fn post_user_groups(
_org_id: String,
user_id: String,
data: JsonUpcase<OrganizationUserUpdateGroupsRequest>,
_headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
put_user_groups(_org_id, user_id, data, _headers, conn).await
}
#[put("/organizations/<_org_id>/users/<user_id>/groups", data = "<data>")]
async fn put_user_groups(
_org_id: String,
user_id: String,
data: JsonUpcase<OrganizationUserUpdateGroupsRequest>,
_headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
match UserOrganization::find_by_uuid(&user_id, &conn).await {
Some(_) => { /* Do nothing */ }
_ => err!("User could not be found!"),
};
GroupUser::delete_all_by_user(&user_id, &conn).await?;
let assigned_group_ids = data.into_inner().data;
for assigned_group_id in assigned_group_ids.GroupIds {
let mut group_user = GroupUser::new(assigned_group_id.clone(), user_id.clone());
group_user.save(&conn).await?;
}
Ok(())
}
#[post("/organizations/<org_id>/groups/<group_id>/delete-user/<user_id>")]
async fn post_delete_group_user(
org_id: String,
group_id: String,
user_id: String,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
delete_group_user(org_id, group_id, user_id, headers, conn).await
}
#[delete("/organizations/<_org_id>/groups/<group_id>/users/<user_id>")]
async fn delete_group_user(
_org_id: String,
group_id: String,
user_id: String,
_headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
match UserOrganization::find_by_uuid(&user_id, &conn).await {
Some(_) => { /* Do nothing */ }
_ => err!("User could not be found!"),
};
match Group::find_by_uuid(&group_id, &conn).await {
Some(_) => { /* Do nothing */ }
_ => err!("Group could not be found!"),
};
GroupUser::delete_by_group_id_and_user_id(&group_id, &user_id, &conn).await
}
// This is a new function active since the v2022.9.x clients.
// It combines the previous two calls done before.
// We call those two functions here and combine them our selfs.

View File

@ -33,6 +33,7 @@ pub type EmptyResult = ApiResult<()>;
type JsonUpcase<T> = Json<util::UpCase<T>>;
type JsonUpcaseVec<T> = Json<Vec<util::UpCase<T>>>;
type JsonVec<T> = Json<Vec<T>>;
// Common structs representing JSON data received
#[derive(Deserialize)]

View File

@ -2,7 +2,9 @@ use crate::CONFIG;
use chrono::{Duration, NaiveDateTime, Utc};
use serde_json::Value;
use super::{Attachment, CollectionCipher, Favorite, FolderCipher, User, UserOrgStatus, UserOrgType, UserOrganization};
use super::{
Attachment, CollectionCipher, Favorite, FolderCipher, Group, User, UserOrgStatus, UserOrgType, UserOrganization,
};
use crate::api::core::CipherSyncData;
@ -337,7 +339,7 @@ impl Cipher {
}
/// Returns whether this cipher is owned by an org in which the user has full access.
pub async fn is_in_full_access_org(
async fn is_in_full_access_org(
&self,
user_uuid: &str,
cipher_sync_data: Option<&CipherSyncData>,
@ -355,6 +357,23 @@ impl Cipher {
false
}
/// Returns whether this cipher is owned by an group in which the user has full access.
async fn is_in_full_access_group(
&self,
user_uuid: &str,
cipher_sync_data: Option<&CipherSyncData>,
conn: &DbConn,
) -> bool {
if let Some(ref org_uuid) = self.organization_uuid {
if let Some(cipher_sync_data) = cipher_sync_data {
return cipher_sync_data.user_group_full_access_for_organizations.get(org_uuid).is_some();
} else {
return Group::is_in_full_access_group(user_uuid, org_uuid, conn).await;
}
}
false
}
/// Returns the user's access restrictions to this cipher. A return value
/// of None means that this cipher does not belong to the user, and is
/// not in any collection the user has access to. Otherwise, the user has
@ -369,7 +388,10 @@ impl Cipher {
// Check whether this cipher is directly owned by the user, or is in
// a collection that the user has full access to. If so, there are no
// access restrictions.
if self.is_owned_by_user(user_uuid) || self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await {
if self.is_owned_by_user(user_uuid)
|| self.is_in_full_access_org(user_uuid, cipher_sync_data, conn).await
|| self.is_in_full_access_group(user_uuid, cipher_sync_data, conn).await
{
return Some((false, false));
}
@ -377,14 +399,22 @@ impl Cipher {
let mut rows: Vec<(bool, bool)> = Vec::new();
if let Some(collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
for collection in collections {
//User permissions
if let Some(uc) = cipher_sync_data.user_collections.get(collection) {
rows.push((uc.read_only, uc.hide_passwords));
}
//Group permissions
if let Some(cg) = cipher_sync_data.user_collections_groups.get(collection) {
rows.push((cg.read_only, cg.hide_passwords));
}
}
}
rows
} else {
self.get_collections_access_flags(user_uuid, conn).await
let mut access_flags = self.get_user_collections_access_flags(user_uuid, conn).await;
access_flags.append(&mut self.get_group_collections_access_flags(user_uuid, conn).await);
access_flags
};
if rows.is_empty() {
@ -411,7 +441,7 @@ impl Cipher {
Some((read_only, hide_passwords))
}
pub async fn get_collections_access_flags(&self, user_uuid: &str, conn: &DbConn) -> Vec<(bool, bool)> {
async fn get_user_collections_access_flags(&self, user_uuid: &str, conn: &DbConn) -> Vec<(bool, bool)> {
db_run! {conn: {
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
@ -424,7 +454,30 @@ impl Cipher {
.and(users_collections::user_uuid.eq(user_uuid))))
.select((users_collections::read_only, users_collections::hide_passwords))
.load::<(bool, bool)>(conn)
.expect("Error getting access restrictions")
.expect("Error getting user access restrictions")
}}
}
async fn get_group_collections_access_flags(&self, user_uuid: &str, conn: &DbConn) -> Vec<(bool, bool)> {
db_run! {conn: {
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.inner_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid)
))
.inner_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
))
.inner_join(groups_users::table.on(
groups_users::groups_uuid.eq(collections_groups::groups_uuid)
))
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.select((collections_groups::read_only, collections_groups::hide_passwords))
.load::<(bool, bool)>(conn)
.expect("Error getting group access restrictions")
}}
}
@ -477,10 +530,10 @@ impl Cipher {
// Find all ciphers accessible or visible to the specified user.
//
// "Accessible" means the user has read access to the cipher, either via
// direct ownership or via collection access.
// direct ownership, collection or via group access.
//
// "Visible" usually means the same as accessible, except when an org
// owner/admin sets their account to have access to only selected
// owner/admin sets their account or group to have access to only selected
// collections in the org (presumably because they aren't interested in
// the other collections in the org). In this case, if `visible_only` is
// true, then the non-interesting ciphers will not be returned. As a
@ -502,9 +555,22 @@ impl Cipher {
// Ensure that users_collections::user_uuid is NULL for unconfirmed users.
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))
))
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
collections_groups::groups_uuid.eq(groups::uuid)
)
))
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
.or_filter(groups::access_all.eq(true)) // Access via groups
.or_filter(collections_groups::collections_uuid.is_not_null()) // Access via groups
.into_boxed();
if !visible_only {
@ -630,11 +696,22 @@ impl Cipher {
users_collections::user_uuid.eq(user_id)
)
))
.filter(users_collections::user_uuid.eq(user_id).or( // User has access to collection
users_organizations::access_all.eq(true).or( // User has access all
users_organizations::atype.le(UserOrgType::Admin as i32) // User is admin or owner
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(
collections_groups::groups_uuid.eq(groups::uuid)
)
))
.or_filter(users_collections::user_uuid.eq(user_id)) // User has access to collection
.or_filter(users_organizations::access_all.eq(true)) // User has access all
.or_filter(users_organizations::atype.le(UserOrgType::Admin as i32)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.select(ciphers_collections::all_columns)
.load::<(String, String)>(conn).unwrap_or_default()
}}

View File

@ -1,6 +1,6 @@
use serde_json::Value;
use super::{User, UserOrgStatus, UserOrgType, UserOrganization};
use super::{CollectionGroup, User, UserOrgStatus, UserOrgType, UserOrganization};
db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -127,6 +127,7 @@ impl Collection {
self.update_users_revision(conn).await;
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?;
db_run! { conn: {
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
@ -171,14 +172,33 @@ impl Collection {
users_organizations::user_uuid.eq(user_uuid)
)
))
.left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid)
))
.left_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
collections_groups::collections_uuid.eq(collections::uuid)
)
))
.filter(
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
)
.filter(
users_collections::user_uuid.eq(user_uuid).or( // Directly accessed collection
users_organizations::access_all.eq(true) // access_all in Organization
).or(
groups::access_all.eq(true) // access_all in groups
).or( // access via groups
groups_users::users_organizations_uuid.eq(users_organizations::uuid).and(
collections_groups::collections_uuid.is_not_null()
)
)
).select(collections::all_columns)
)
.select(collections::all_columns)
.distinct()
.load::<CollectionDb>(conn).expect("Error loading collections").from_db()
}}
}

501
src/db/models/group.rs Normal file
View File

@ -0,0 +1,501 @@
use chrono::{NaiveDateTime, Utc};
use serde_json::Value;
db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[table_name = "groups"]
#[primary_key(uuid)]
pub struct Group {
pub uuid: String,
pub organizations_uuid: String,
pub name: String,
pub access_all: bool,
external_id: Option<String>,
pub creation_date: NaiveDateTime,
pub revision_date: NaiveDateTime,
}
#[derive(Identifiable, Queryable, Insertable)]
#[table_name = "collections_groups"]
#[primary_key(collections_uuid, groups_uuid)]
pub struct CollectionGroup {
pub collections_uuid: String,
pub groups_uuid: String,
pub read_only: bool,
pub hide_passwords: bool,
}
#[derive(Identifiable, Queryable, Insertable)]
#[table_name = "groups_users"]
#[primary_key(groups_uuid, users_organizations_uuid)]
pub struct GroupUser {
pub groups_uuid: String,
pub users_organizations_uuid: String
}
}
/// Local methods
impl Group {
pub fn new(organizations_uuid: String, name: String, access_all: bool, external_id: Option<String>) -> Self {
let now = Utc::now().naive_utc();
let mut new_model = Self {
uuid: crate::util::get_uuid(),
organizations_uuid,
name,
access_all,
external_id: None,
creation_date: now,
revision_date: now,
};
new_model.set_external_id(external_id);
new_model
}
pub fn to_json(&self) -> Value {
use crate::util::format_date;
json!({
"Id": self.uuid,
"OrganizationId": self.organizations_uuid,
"Name": self.name,
"AccessAll": self.access_all,
"ExternalId": self.external_id,
"CreationDate": format_date(&self.creation_date),
"RevisionDate": format_date(&self.revision_date)
})
}
pub fn set_external_id(&mut self, external_id: Option<String>) {
//Check if external id is empty. We don't want to have
//empty strings in the database
match external_id {
Some(external_id) => {
if external_id.is_empty() {
self.external_id = None;
} else {
self.external_id = Some(external_id)
}
}
None => self.external_id = None,
}
}
pub fn get_external_id(&self) -> Option<String> {
self.external_id.clone()
}
}
impl CollectionGroup {
pub fn new(collections_uuid: String, groups_uuid: String, read_only: bool, hide_passwords: bool) -> Self {
Self {
collections_uuid,
groups_uuid,
read_only,
hide_passwords,
}
}
}
impl GroupUser {
pub fn new(groups_uuid: String, users_organizations_uuid: String) -> Self {
Self {
groups_uuid,
users_organizations_uuid,
}
}
}
use crate::db::DbConn;
use crate::api::EmptyResult;
use crate::error::MapResult;
use super::{User, UserOrganization};
/// Database methods
impl Group {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups::table)
.values(GroupDb::to_db(self))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups::table)
.filter(groups::uuid.eq(&self.uuid))
.set(GroupDb::to_db(self))
.execute(conn)
.map_res("Error saving group")
}
Err(e) => Err(e.into()),
}.map_res("Error saving group")
}
postgresql {
let value = GroupDb::to_db(self);
diesel::insert_into(groups::table)
.values(&value)
.on_conflict(groups::uuid)
.do_update()
.set(&value)
.execute(conn)
.map_res("Error saving group")
}
}
}
pub async fn find_by_organization(organizations_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
groups::table
.filter(groups::organizations_uuid.eq(organizations_uuid))
.load::<GroupDb>(conn)
.expect("Error loading groups")
.from_db()
}}
}
pub async fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
db_run! { conn: {
groups::table
.filter(groups::uuid.eq(uuid))
.first::<GroupDb>(conn)
.ok()
.from_db()
}}
}
//Returns all organizations the user has full access to
pub async fn gather_user_organizations_full_access(user_uuid: &str, conn: &DbConn) -> Vec<String> {
db_run! { conn: {
groups_users::table
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.inner_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::access_all.eq(true))
.select(groups::organizations_uuid)
.distinct()
.load::<String>(conn)
.expect("Error loading organization group full access information for user")
}}
}
pub async fn is_in_full_access_group(user_uuid: &str, org_uuid: &str, conn: &DbConn) -> bool {
db_run! { conn: {
groups::table
.inner_join(groups_users::table.on(
groups_users::groups_uuid.eq(groups::uuid)
))
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::organizations_uuid.eq(org_uuid))
.filter(groups::access_all.eq(true))
.select(groups::access_all)
.first::<bool>(conn)
.unwrap_or_default()
}}
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
CollectionGroup::delete_all_by_group(&self.uuid, conn).await?;
GroupUser::delete_all_by_group(&self.uuid, conn).await?;
db_run! { conn: {
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
.execute(conn)
.map_res("Error deleting group")
}}
}
pub async fn update_revision(uuid: &str, conn: &DbConn) {
if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn).await {
warn!("Failed to update revision for {}: {:#?}", uuid, e);
}
}
async fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
db_run! {conn: {
crate::util::retry(|| {
diesel::update(groups::table.filter(groups::uuid.eq(uuid)))
.set(groups::revision_date.eq(date))
.execute(conn)
}, 10)
.map_res("Error updating group revision")
}}
}
}
impl CollectionGroup {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(collections_groups::table)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections_groups::table)
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
.set((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
))
.execute(conn)
.map_res("Error adding group to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding group to collection")
}
postgresql {
diesel::insert_into(collections_groups::table)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
))
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
.do_update()
.set((
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
))
.execute(conn)
.map_res("Error adding group to collection")
}
}
}
pub async fn find_by_group(group_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
collections_groups::table
.filter(collections_groups::groups_uuid.eq(group_uuid))
.load::<CollectionGroupDb>(conn)
.expect("Error loading collection groups")
.from_db()
}}
}
pub async fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
collections_groups::table
.inner_join(groups_users::table.on(
groups_users::groups_uuid.eq(collections_groups::groups_uuid)
))
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
))
.filter(users_organizations::user_uuid.eq(user_uuid))
.select(collections_groups::all_columns)
.load::<CollectionGroupDb>(conn)
.expect("Error loading user collection groups")
.from_db()
}}
}
pub async fn find_by_collection(collection_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
collections_groups::table
.filter(collections_groups::collections_uuid.eq(collection_uuid))
.select(collections_groups::all_columns)
.load::<CollectionGroupDb>(conn)
.expect("Error loading collection groups")
.from_db()
}}
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
db_run! { conn: {
diesel::delete(collections_groups::table)
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
.execute(conn)
.map_res("Error deleting collection group")
}}
}
pub async fn delete_all_by_group(group_uuid: &str, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
db_run! { conn: {
diesel::delete(collections_groups::table)
.filter(collections_groups::groups_uuid.eq(group_uuid))
.execute(conn)
.map_res("Error deleting collection group")
}}
}
pub async fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult {
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
for collection_assigned_to_group in collection_assigned_to_groups {
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
}
db_run! { conn: {
diesel::delete(collections_groups::table)
.filter(collections_groups::collections_uuid.eq(collection_uuid))
.execute(conn)
.map_res("Error deleting collection group")
}}
}
}
impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.update_user_revision(conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups_users::table)
.filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid))
.filter(groups_users::groups_uuid.eq(&self.groups_uuid))
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
.map_res("Error adding user to group")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to group")
}
postgresql {
diesel::insert_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
.do_update()
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
.map_res("Error adding user to group")
}
}
}
pub async fn find_by_group(group_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
groups_users::table
.filter(groups_users::groups_uuid.eq(group_uuid))
.load::<GroupUserDb>(conn)
.expect("Error loading group users")
.from_db()
}}
}
pub async fn find_by_user(users_organizations_uuid: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
groups_users::table
.filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid))
.load::<GroupUserDb>(conn)
.expect("Error loading groups for user")
.from_db()
}}
}
pub async fn update_user_revision(&self, conn: &DbConn) {
match UserOrganization::find_by_uuid(&self.users_organizations_uuid, conn).await {
Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await,
None => warn!("User could not be found!"),
}
}
pub async fn delete_by_group_id_and_user_id(
group_uuid: &str,
users_organizations_uuid: &str,
conn: &DbConn,
) -> EmptyResult {
match UserOrganization::find_by_uuid(users_organizations_uuid, conn).await {
Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await,
None => warn!("User could not be found!"),
};
db_run! { conn: {
diesel::delete(groups_users::table)
.filter(groups_users::groups_uuid.eq(group_uuid))
.filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid))
.execute(conn)
.map_res("Error deleting group users")
}}
}
pub async fn delete_all_by_group(group_uuid: &str, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, conn).await;
for group_user in group_users {
group_user.update_user_revision(conn).await;
}
db_run! { conn: {
diesel::delete(groups_users::table)
.filter(groups_users::groups_uuid.eq(group_uuid))
.execute(conn)
.map_res("Error deleting group users")
}}
}
pub async fn delete_all_by_user(users_organizations_uuid: &str, conn: &DbConn) -> EmptyResult {
match UserOrganization::find_by_uuid(users_organizations_uuid, conn).await {
Some(user) => User::update_uuid_revision(&user.user_uuid, conn).await,
None => warn!("User could not be found!"),
}
db_run! { conn: {
diesel::delete(groups_users::table)
.filter(groups_users::users_organizations_uuid.eq(users_organizations_uuid))
.execute(conn)
.map_res("Error deleting user groups")
}}
}
}

View File

@ -5,6 +5,7 @@ mod device;
mod emergency_access;
mod favorite;
mod folder;
mod group;
mod org_policy;
mod organization;
mod send;
@ -19,6 +20,7 @@ pub use self::device::Device;
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessStatus, EmergencyAccessType};
pub use self::favorite::Favorite;
pub use self::folder::{Folder, FolderCipher};
pub use self::group::{CollectionGroup, Group, GroupUser};
pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType};
pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization};
pub use self::send::{Send, SendType};

View File

@ -2,7 +2,7 @@ use num_traits::FromPrimitive;
use serde_json::Value;
use std::cmp::Ordering;
use super::{CollectionUser, OrgPolicy, OrgPolicyType, User};
use super::{CollectionUser, GroupUser, OrgPolicy, OrgPolicyType, User};
db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -148,7 +148,7 @@ impl Organization {
"Use2fa": true,
"UseDirectory": false, // Is supported, but this value isn't checked anywhere (yet)
"UseEvents": false, // Not supported
"UseGroups": false, // Not supported
"UseGroups": true,
"UseTotp": true,
"UsePolicies": true,
// "UseScim": false, // Not supported (Not AGPLv3 Licensed)
@ -300,7 +300,7 @@ impl UserOrganization {
"Use2fa": true,
"UseDirectory": false, // Is supported, but this value isn't checked anywhere (yet)
"UseEvents": false, // Not supported
"UseGroups": false, // Not supported
"UseGroups": true,
"UseTotp": true,
// "UseScim": false, // Not supported (Not AGPLv3 Licensed)
"UsePolicies": true,
@ -459,6 +459,7 @@ impl UserOrganization {
User::update_uuid_revision(&self.user_uuid, conn).await;
CollectionUser::delete_all_by_user_and_org(&self.user_uuid, &self.org_uuid, conn).await?;
GroupUser::delete_all_by_user(&self.uuid, conn).await?;
db_run! { conn: {
diesel::delete(users_organizations::table.filter(users_organizations::uuid.eq(self.uuid)))

View File

@ -220,6 +220,34 @@ table! {
}
}
table! {
groups (uuid) {
uuid -> Text,
organizations_uuid -> Text,
name -> Text,
access_all -> Bool,
external_id -> Nullable<Text>,
creation_date -> Timestamp,
revision_date -> Timestamp,
}
}
table! {
groups_users (groups_uuid, users_organizations_uuid) {
groups_uuid -> Text,
users_organizations_uuid -> Text,
}
}
table! {
collections_groups (collections_uuid, groups_uuid) {
collections_uuid -> Text,
groups_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
}
}
joinable!(attachments -> ciphers (cipher_uuid));
joinable!(ciphers -> organizations (organization_uuid));
joinable!(ciphers -> users (user_uuid));
@ -239,6 +267,11 @@ joinable!(users_collections -> users (user_uuid));
joinable!(users_organizations -> organizations (org_uuid));
joinable!(users_organizations -> users (user_uuid));
joinable!(emergency_access -> users (grantor_uuid));
joinable!(groups -> organizations (organizations_uuid));
joinable!(groups_users -> users_organizations (users_organizations_uuid));
joinable!(groups_users -> groups (groups_uuid));
joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -257,4 +290,7 @@ allow_tables_to_appear_in_same_query!(
users_collections,
users_organizations,
emergency_access,
groups,
groups_users,
collections_groups,
);

View File

@ -220,6 +220,34 @@ table! {
}
}
table! {
groups (uuid) {
uuid -> Text,
organizations_uuid -> Text,
name -> Text,
access_all -> Bool,
external_id -> Nullable<Text>,
creation_date -> Timestamp,
revision_date -> Timestamp,
}
}
table! {
groups_users (groups_uuid, users_organizations_uuid) {
groups_uuid -> Text,
users_organizations_uuid -> Text,
}
}
table! {
collections_groups (collections_uuid, groups_uuid) {
collections_uuid -> Text,
groups_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
}
}
joinable!(attachments -> ciphers (cipher_uuid));
joinable!(ciphers -> organizations (organization_uuid));
joinable!(ciphers -> users (user_uuid));
@ -239,6 +267,11 @@ joinable!(users_collections -> users (user_uuid));
joinable!(users_organizations -> organizations (org_uuid));
joinable!(users_organizations -> users (user_uuid));
joinable!(emergency_access -> users (grantor_uuid));
joinable!(groups -> organizations (organizations_uuid));
joinable!(groups_users -> users_organizations (users_organizations_uuid));
joinable!(groups_users -> groups (groups_uuid));
joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -257,4 +290,7 @@ allow_tables_to_appear_in_same_query!(
users_collections,
users_organizations,
emergency_access,
groups,
groups_users,
collections_groups,
);

View File

@ -220,6 +220,34 @@ table! {
}
}
table! {
groups (uuid) {
uuid -> Text,
organizations_uuid -> Text,
name -> Text,
access_all -> Bool,
external_id -> Nullable<Text>,
creation_date -> Timestamp,
revision_date -> Timestamp,
}
}
table! {
groups_users (groups_uuid, users_organizations_uuid) {
groups_uuid -> Text,
users_organizations_uuid -> Text,
}
}
table! {
collections_groups (collections_uuid, groups_uuid) {
collections_uuid -> Text,
groups_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
}
}
joinable!(attachments -> ciphers (cipher_uuid));
joinable!(ciphers -> organizations (organization_uuid));
joinable!(ciphers -> users (user_uuid));
@ -239,6 +267,11 @@ joinable!(users_collections -> users (user_uuid));
joinable!(users_organizations -> organizations (org_uuid));
joinable!(users_organizations -> users (user_uuid));
joinable!(emergency_access -> users (grantor_uuid));
joinable!(groups -> organizations (organizations_uuid));
joinable!(groups_users -> users_organizations (users_organizations_uuid));
joinable!(groups_users -> groups (groups_uuid));
joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -257,4 +290,7 @@ allow_tables_to_appear_in_same_query!(
users_collections,
users_organizations,
emergency_access,
groups,
groups_users,
collections_groups,
);