Skip to content

Commit

Permalink
Store user info in DB and make them searchable in ACL UI (#1027)
Browse files Browse the repository at this point in the history
Fixes #951 (also removing our dummy users from the code, unblocking the
release)

The main goal of this PR was to make the ACL UI user selection work
nicely. I.e. that one can search through users to add user entries to
the ACL. Previously we had a bunch of hardcoded dummy users (which also
made us unable to release Tobira). This is now done, but lots of related
changed had to be done as well. There is also a configuration option
which controls whether users can actually be searched by name or whether
they can only be found by typing the exact username/email. The latter
mode is the default for data privacy reasons.

User information is remembered whenever a user logs into Tobira or does
anything there. It is also possible to import users from a JSON file.

**Important**: this PR introduces a breaking change as it makes a "user
role" mandatory for users. See second commit. I doubt this is a problem
for anyone, but it's still technically breaking.

---

This can probably be reviewed commit by commit. The commit messages
should be read for sure. However, the changes to `ui/Access.tsx` are
likely very annoying to review because it's also lots of refactoring,
over multiple commits. So yeah, not 100% sure how to best approach that.

---

Finally, there are a few things that still have to be improved. But not
in this PR, it's already large enough. I will create issues for these
after merging.

- [ ] Potentially add some basic user stats to `/~metrics`, e.g. "active
user in last 24h
- [ ] Re-add the paste functionality
- [ ] Stop sending a list of all known groups to the frontend, that
makes stuff slow.
  • Loading branch information
owi92 authored Dec 21, 2023
2 parents b5d784d + 9484852 commit 4f8ce99
Show file tree
Hide file tree
Showing 47 changed files with 1,367 additions and 372 deletions.
16 changes: 11 additions & 5 deletions .deployment/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,21 +185,27 @@
cmd: /opt/tobira/{{ id }}/tobira search-index update
chdir: /opt/tobira/{{ id }}/

- name: Add test-data known groups
- name: Add test-data known groups/users
become: true
copy:
src: known-groups.json
dest: /opt/tobira/{{ id }}/known-groups.json
src: known-{{item}}.json
dest: /opt/tobira/{{ id }}/known-{{item}}.json
owner: root
group: root
mode: '0644'
with_items:
- groups
- users

- name: Configure known groups
- name: Configure known groups/users
become: true
become_user: tobira
command:
cmd: /opt/tobira/{{ id }}/tobira known-groups upsert known-groups.json
cmd: /opt/tobira/{{ id }}/tobira known-{{item}} upsert known-{{item}}.json
chdir: /opt/tobira/{{ id }}/
with_items:
- groups
- users

- name: install tobira service files
become: true
Expand Down
6 changes: 6 additions & 0 deletions .deployment/files/known-users.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"ROLE_USER_SABINE": { "username": "sabine", "displayName": "Sabine Rudolfs", "email": "[email protected]" },
"ROLE_USER_BJOERK": { "username": "björk", "displayName": "Prof. Björk Guðmundsdóttir", "email": "[email protected]" },
"ROLE_USER_MORGAN": { "username": "morgan", "displayName": "Morgan Yu", "email": "[email protected]" },
"ROLE_USER_JOSE": { "username": "jose", "displayName": "José Carreño Quiñones" }
}
2 changes: 1 addition & 1 deletion .github/workflows/upload-db-dump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:10
image: postgres:12
env:
POSTGRES_USER: tobira
POSTGRES_PASSWORD: tobira
Expand Down
14 changes: 14 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "std"
clap = { version = "4.2.2", features = ["derive", "string"] }
confique = { version = "0.2.0", default-features = false, features = ["toml"] }
cookie = "0.17.0"
dashmap = "5.5.3"
deadpool = { version = "0.9.0", default-features = false, features = ["managed", "rt_tokio_1"] }
deadpool-postgres = { version = "0.10", default-features = false, features = ["rt_tokio_1"] }
elliptic-curve = { version = "0.13.4", features = ["jwk", "sec1"] }
Expand Down
96 changes: 96 additions & 0 deletions backend/src/api/model/acl.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use juniper::GraphQLObject;
use postgres_types::BorrowToSql;

use crate::{api::{util::TranslatedString, Context, err::ApiResult}, db::util::select};




pub(crate) type Acl = Vec<AclItem>;

/// A role being granted permission to perform certain actions.
#[derive(Debug, GraphQLObject)]
#[graphql(context = Context)]
pub(crate) struct AclItem {
/// Role. In arrays of AclItems, no two items have the same `role`.
pub role: String,

/// List of actions this role can perform (e.g. `read`, `write`,
/// `annotate`). This is a set, i.e. no duplicate elements.
pub actions: Vec<String>,

/// Additional info we have about the role. Is `null` if the role is unknown
/// or is `ROLE_ANONYMOUS`, `ROLE_ADMIN` or `ROLE_USER`, as those are
/// handled in a special way in the frontend.
pub info: Option<RoleInfo>,
}

/// Some extra information we know about a role.
#[derive(Debug, GraphQLObject)]
#[graphql(context = Context)]
pub(crate) struct RoleInfo {
/// A user-facing label for this role (group or person). If the label does
/// not depend on the language (e.g. a name), `{ "_": "Peter" }` is
/// returned.
pub label: TranslatedString<String>,

/// For user roles this is `null`. For groups, it defines a list of other
/// group roles that this role implies. I.e. a user with this role always
/// also has these other roles.
pub implies: Option<Vec<String>>,

/// Is `true` if this role represents a large group. Used to warn users
/// accidentally giving write access to large groups.
pub large: bool,
}

pub(crate) async fn load_for<P, I>(
context: &Context,
raw_roles: &str,
params: I,
) -> ApiResult<Acl>
where
P: BorrowToSql,
I: IntoIterator<Item = P> + std::fmt::Debug,
I::IntoIter: ExactSizeIterator,
{
// First: load labels for roles from the DB. For that we use the `users`
// and `known_groups` table.
let (selection, mapping) = select!(
role: "roles.role",
actions,
implies,
large: "coalesce(known_groups.large, false)",
label: "coalesce(
known_groups.label,
case when users.display_name is null
then null
else hstore('_', users.display_name)
end
)",
);
let sql = format!("\
with raw_roles as ({raw_roles}),
roles as (
select role, array_agg(action) as actions
from raw_roles
group by role
)
select {selection}
from roles
left join users on users.user_role = role
left join known_groups on known_groups.role = roles.role\
");

context.db.query_mapped(&sql, params, |row| {
AclItem {
role: mapping.role.of(&row),
actions: mapping.actions.of(&row),
info: mapping.label.of::<Option<_>>(&row).map(|label| RoleInfo {
label,
implies: mapping.implies.of(&row),
large: mapping.large.of(&row),
}),
}
}).await.map_err(Into::into)
}
11 changes: 10 additions & 1 deletion backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
Context, Cursor, Id, Node, NodeValue,
common::NotAllowed,
err::{self, ApiResult, invalid_input},
model::{series::Series, realm::Realm},
model::{series::Series, realm::Realm, acl::{Acl, self}},
},
db::{
types::{EventTrack, EventState, Key, ExtraMetadata, EventCaption},
Expand Down Expand Up @@ -221,6 +221,15 @@ impl AuthorizedEvent {
.await?
.pipe(Ok)
}

async fn acl(&self, context: &Context) -> ApiResult<Acl> {
let raw_roles_sql = "\
select unnest(read_roles) as role, 'read' as action from events where id = $1
union
select unnest(write_roles) as role, 'write' as action from events where id = $1
";
acl::load_for(context, raw_roles_sql, dbargs![&self.key]).await
}
}

#[derive(juniper::GraphQLUnion)]
Expand Down
83 changes: 82 additions & 1 deletion backend/src/api/model/known_roles.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
use meilisearch_sdk::{Selectors, MatchingStrategies};
use serde::Deserialize;

use crate::{
api::{Context, err::ApiResult, util::TranslatedString},
prelude::*,
db::util::impl_from_db,
db::util::{impl_from_db, select},
};
use super::search::{SearchUnavailable, SearchResults, handle_search_result};


// ===== Groups ===============================================================

/// A group selectable in the ACL UI. Basically a mapping from role to a nice
/// label and info about the relationship to other roles/groups.
Expand Down Expand Up @@ -40,3 +45,79 @@ impl KnownGroup {
.pipe(Ok)
}
}

// ===== Users ===============================================================

#[derive(juniper::GraphQLUnion)]
#[graphql(Context = Context)]
pub(crate) enum KnownUsersSearchOutcome {
SearchUnavailable(SearchUnavailable),
Results(SearchResults<KnownUser>),
}

#[derive(juniper::GraphQLObject, Deserialize)]
#[graphql(Context = Context)]
pub(crate) struct KnownUser {
display_name: String,
user_role: String,
}

#[juniper::graphql_object(Context = Context, name = "KnownUserSearchResults")]
impl SearchResults<KnownUser> {
fn items(&self) -> &[KnownUser] {
&self.items
}
}

pub(crate) async fn search_known_users(
query: String,
context: &Context,
) -> ApiResult<KnownUsersSearchOutcome> {
if !context.auth.is_user() {
return Err(context.not_logged_in_error());
}

// Load users with exact match from DB.
let db_load = async {
let (selection, mapping) = select!(display_name, user_role);
let sql = format!("select {selection} \
from users \
where lower(username) = $1 \
or lower(email) = $1 \
or lower(user_role) = $1 \
limit 50"
);
context.db.query_mapped(&sql, dbargs![&query.to_lowercase()], |row| {
KnownUser {
display_name: mapping.display_name.of(&row),
user_role: mapping.user_role.of(&row),
}
}).await
};

// If the settings allow it, search users via MeiliSearch
let meili_search = async {
if context.config.general.users_searchable {
context.search.user_index.search()
.with_query(&query)
.with_limit(50)
.with_matching_strategy(MatchingStrategies::ALL)
.with_attributes_to_retrieve(Selectors::Some(&["display_name", "user_role"]))
.execute::<KnownUser>()
.await
.pipe(Some)
} else {
None
}
};

// Run both loads concurrently and combine results.
let (db_results, meili_results) = tokio::join!(db_load, meili_search);
let mut items = db_results?;
if let Some(res) = meili_results {
let results = handle_search_result!(res, KnownUsersSearchOutcome);
items.extend(results.hits.into_iter().map(|h| h.result));
}

Ok(KnownUsersSearchOutcome::Results(SearchResults { items }))
}
1 change: 1 addition & 0 deletions backend/src/api/model/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! This module and its children define most of the application logic of the
//! API.
pub(crate) mod acl;
pub(crate) mod block;
pub(crate) mod event;
pub(crate) mod known_roles;
Expand Down
22 changes: 12 additions & 10 deletions backend/src/api/model/search/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
use once_cell::sync::Lazy;
use regex::Regex;
use std::{fmt, borrow::Cow};
use meilisearch_sdk::errors::{
Error as MsError,
MeilisearchError as MsRespError,
ErrorCode as MsErrorCode,
};

use crate::{
api::{
Expand Down Expand Up @@ -47,7 +42,7 @@ pub(crate) enum SearchOutcome {
}

pub(crate) struct SearchResults<T> {
items: Vec<T>,
pub(crate) items: Vec<T>,
}

#[juniper::graphql_object(Context = Context)]
Expand All @@ -73,7 +68,13 @@ impl SearchResults<search::Series> {


macro_rules! handle_search_result {
($res:expr, $return_type:ty) => {
($res:expr, $return_type:ty) => {{
use meilisearch_sdk::errors::{
Error as MsError,
MeilisearchError as MsRespError,
ErrorCode as MsErrorCode,
};

match $res {
Ok(v) => v,

Expand All @@ -97,12 +98,13 @@ macro_rules! handle_search_result {
}

// All other errors just result in a general "internal server error".
Err(e) => return Err(e.into()),
Err(e) => return ApiResult::Err(e.into()),
}

};
}};
}

pub(crate) use handle_search_result;


/// Main entry point for the main search (including all items).
pub(crate) async fn perform(
Expand Down
7 changes: 7 additions & 0 deletions backend/src/api/model/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ impl User {
self.roles.iter().map(AsRef::as_ref).collect()
}

/// Returns the *user role* of this user. Each user has exactly one and this
/// role is used in ACLs to give access to a single user. This role is
/// always also contained in `roles`.
fn user_role(&self) -> &str {
&self.user_role
}

/// The name of the user intended to be read by humans.
fn display_name(&self) -> &str {
&self.display_name
Expand Down
Loading

0 comments on commit 4f8ce99

Please sign in to comment.