Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for event specific password protection #1244

Merged
merged 26 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b00d85a
Add migration and API for new preview_roles
owi92 Aug 20, 2024
f5898d7
Move some event data to `authorizedData` field
owi92 Aug 21, 2024
7d003dd
Apply UI changes related to new preview roles and authorized data
owi92 Aug 26, 2024
dc7f407
Hide search thumbnails for users without read access
owi92 Sep 12, 2024
5480f3e
Sync preview roles when harvesting
owi92 Aug 27, 2024
978a6d2
Add endpoints and UI for preliminary authentication
owi92 Sep 12, 2024
6ecf99e
Add authentication mask to password protected videos
owi92 Sep 2, 2024
2bd5bcb
Make event authentication "sticky"
owi92 Sep 12, 2024
44255d7
Add eth-password interpretation
owi92 Sep 9, 2024
ef097da
Hide text results for events with passwords
owi92 Sep 12, 2024
e2164ab
Add migration and sync for series credentials
owi92 Oct 16, 2024
4f6d742
Fix infinite spinner and flashing screen
owi92 Oct 17, 2024
f30f21d
Allow authentication via series id
owi92 Oct 19, 2024
35e0a07
Add authenticated data context to video pages and realms
owi92 Oct 20, 2024
176fa0b
Stop merging `read_roles` into `preview_roles`
LukasKalbertodt Nov 12, 2024
34b61d7
Rework `Filter` helper in search
LukasKalbertodt Nov 13, 2024
a0d82b3
Remove DB trigger to transfer series credentials to event
LukasKalbertodt Nov 14, 2024
8e7d50e
Stop sending search text matches for videos with only preview access
LukasKalbertodt Nov 14, 2024
64ecc2c
Remove superfluous custom operation type
LukasKalbertodt Nov 14, 2024
571c25b
Remove password query requests from `<Thumbnail>`
LukasKalbertodt Nov 14, 2024
4059430
Get rid of `AuthenticatedDataContext` by using relay store
LukasKalbertodt Nov 18, 2024
e5d3a45
Show correct "lock" thumbnails for preview-only videos in search
LukasKalbertodt Nov 19, 2024
f2fdc08
Use series credentials to unlock events and improve creds type-safety
LukasKalbertodt Nov 19, 2024
4031e07
Bump search index version
LukasKalbertodt Nov 19, 2024
8e50478
Modify db dump config
owi92 Nov 21, 2024
eee097c
Move thumbnail back to synced data
owi92 Nov 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .deployment/templates/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ host = "https://tobira-test-oc.ethz.ch"
user = "admin"
password = "{{ opencast_admin_password }}"
poll_period = "1min"
interpret_eth_passwords = true

[theme]
logo.large.path = "/opt/tobira/{{ id }}/logo-large.svg"
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/upload-db-dump.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
run: |
sed --in-place \
-e 's/host = "http:\/\/localhost:8081"/host = "https:\/\/tobira-test-oc.ethz.ch"/g' \
-e '/password = "opencast"/a\interpret_eth_passwords = true' \
-e 's/password = "opencast"/password = "${{ secrets.TOBIRA_OPENCAST_ADMIN_PASSWORD }}"/g' \
-e 's/level = "trace"/level = "debug"/g' \
-e '/preferred_harvest_size/d' \
Expand Down
12 changes: 12 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 @@ -84,6 +84,7 @@ tracing-subscriber = "0.3.18"
reqwest = "0.12.4"
subtp = "0.2.0"
xmlparser = "0.13.6"
sha1 = "0.10.6"

[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.16.0"
Expand Down
6 changes: 3 additions & 3 deletions backend/src/api/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ use crate::{
Context,
err::{self, ApiResult},
model::{
event::AuthorizedEvent,
series::Series,
realm::Realm,
event::AuthorizedEvent,
series::Series,
realm::Realm,
playlist::AuthorizedPlaylist,
search::{SearchEvent, SearchRealm, SearchSeries},
},
Expand Down
88 changes: 78 additions & 10 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use postgres_types::ToSql;
use serde::{Serialize, Deserialize};
use tokio_postgres::Row;
use juniper::{GraphQLObject, graphql_object};
use sha1::{Sha1, Digest};

use crate::{
api::{
Expand All @@ -13,7 +14,7 @@ use crate::{
model::{acl::{self, Acl}, realm::Realm, series::Series},
},
db::{
types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key},
types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key, Credentials},
util::{impl_from_db, select},
},
prelude::*,
Expand All @@ -37,8 +38,11 @@ pub(crate) struct AuthorizedEvent {
pub(crate) metadata: ExtraMetadata,
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,
pub(crate) preview_roles: Vec<String>,
pub(crate) credentials: Option<Credentials>,

pub(crate) synced_data: Option<SyncedEventData>,
pub(crate) authorized_data: Option<AuthorizedEventData>,
pub(crate) tobira_deletion_timestamp: Option<DateTime<Utc>>,
}

Expand All @@ -47,11 +51,15 @@ pub(crate) struct SyncedEventData {
updated: DateTime<Utc>,
start_time: Option<DateTime<Utc>>,
end_time: Option<DateTime<Utc>>,

thumbnail: Option<String>,
/// Duration in milliseconds
duration: i64,
audio_only: bool,
}

#[derive(Debug)]
pub(crate) struct AuthorizedEventData {
tracks: Vec<Track>,
thumbnail: Option<String>,
captions: Vec<Caption>,
segments: Vec<Segment>,
}
Expand All @@ -64,11 +72,12 @@ impl_from_db!(
title, description, duration, creators, thumbnail, metadata,
created, updated, start_time, end_time,
tracks, captions, segments,
read_roles, write_roles,
read_roles, write_roles, preview_roles, credentials,
tobira_deletion_timestamp,
},
},
|row| {
let tracks: Vec<Track> = row.tracks::<Vec<EventTrack>>().into_iter().map(Track::from).collect();
Self {
key: row.id(),
series: row.series(),
Expand All @@ -81,6 +90,8 @@ impl_from_db!(
metadata: row.metadata(),
read_roles: row.read_roles::<Vec<String>>(),
write_roles: row.write_roles::<Vec<String>>(),
preview_roles: row.preview_roles::<Vec<String>>(),
credentials: row.credentials(),
tobira_deletion_timestamp: row.tobira_deletion_timestamp(),
synced_data: match row.state::<EventState>() {
EventState::Ready => Some(SyncedEventData {
Expand All @@ -89,7 +100,13 @@ impl_from_db!(
end_time: row.end_time(),
duration: row.duration(),
thumbnail: row.thumbnail(),
tracks: row.tracks::<Vec<EventTrack>>().into_iter().map(Track::from).collect(),
audio_only: tracks.iter().all(|t| t.resolution.is_none()),
}),
EventState::Waiting => None,
},
authorized_data: match row.state::<EventState>() {
EventState::Ready => Some(AuthorizedEventData {
tracks,
captions: row.captions::<Vec<EventCaption>>()
.into_iter()
.map(Caption::from)
Expand Down Expand Up @@ -150,12 +167,21 @@ impl SyncedEventData {
fn duration(&self) -> f64 {
self.duration as f64
}
fn tracks(&self) -> &[Track] {
&self.tracks
}
fn thumbnail(&self) -> Option<&str> {
self.thumbnail.as_deref()
}
fn audio_only(&self) -> bool {
self.audio_only
}
}

/// Represents event data that is only accessible for users with read access
/// and event-specific authenticated users.
#[graphql_object(Context = Context, impl = NodeValue)]
impl AuthorizedEventData {
LukasKalbertodt marked this conversation as resolved.
Show resolved Hide resolved
fn tracks(&self) -> &[Track] {
&self.tracks
}
fn captions(&self) -> &[Caption] {
&self.captions
}
Expand Down Expand Up @@ -198,11 +224,42 @@ impl AuthorizedEvent {
fn write_roles(&self) -> &[String] {
&self.write_roles
}
/// This doesn't contain `ROLE_ADMIN` as that is included implicitly.
fn preview_roles(&self) -> &[String] {
&self.preview_roles
}

fn synced_data(&self) -> &Option<SyncedEventData> {
&self.synced_data
}

/// Returns the authorized event data if the user has read access or is authenticated for the event.
async fn authorized_data(
&self,
context: &Context,
user: Option<String>,
password: Option<String>,
) -> Option<&AuthorizedEventData> {
let sha1_matches = |input: &str, encoded: &str| {
let (algo, hash) = encoded.split_once(':').expect("invalid credentials in DB");
match algo {
"sha1" => hash == hex::encode_upper(Sha1::digest(input)),
_ => unreachable!("unsupported hash algo"),
}
};

let credentials_match = self.credentials.as_ref().map_or(false, |credentials| {
user.map_or(false, |u| sha1_matches(&u, &credentials.name))
&& password.map_or(false, |p| sha1_matches(&p, &credentials.password))
});

if context.auth.overlaps_roles(&self.read_roles) || credentials_match {
self.authorized_data.as_ref()
} else {
None
}
}

/// Whether the current user has write access to this event.
fn can_write(&self, context: &Context) -> bool {
context.auth.overlaps_roles(&self.write_roles)
Expand Down Expand Up @@ -238,6 +295,12 @@ impl AuthorizedEvent {
).await?.pipe(Ok)
}


/// Whether this event is password protected.
async fn has_password(&self) -> bool {
self.credentials.is_some()
}

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
Expand Down Expand Up @@ -306,7 +369,7 @@ impl AuthorizedEvent {
.await?
.map(|row| {
let event = Self::from_row_start(&row);
if context.auth.overlaps_roles(&event.read_roles) {
if event.can_be_previewed(context) {
Event::Event(event)
} else {
Event::NotAllowed(NotAllowed)
Expand All @@ -327,7 +390,7 @@ impl AuthorizedEvent {
context.db
.query_mapped(&query, dbargs![&series_key], |row| {
let event = Self::from_row_start(&row);
if !context.auth.overlaps_roles(&event.read_roles) {
if !event.can_be_previewed(context) {
return VideoListEntry::NotAllowed(NotAllowed);
}

Expand All @@ -337,6 +400,11 @@ impl AuthorizedEvent {
.pipe(Ok)
}

fn can_be_previewed(&self, context: &Context) -> bool {
context.auth.overlaps_roles(&self.preview_roles)
|| context.auth.overlaps_roles(&self.read_roles)
}

pub(crate) async fn delete(id: Id, context: &Context) -> ApiResult<RemovedEvent> {
let event = Self::load_by_id(id, context)
.await?
Expand Down
42 changes: 27 additions & 15 deletions backend/src/api/model/search/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ use juniper::GraphQLObject;

use crate::{
api::{Context, Id, Node, NodeValue},
auth::HasRoles,
db::types::TextAssetType,
search,
search::{self, util::decode_acl},
};
use super::{field_matches_for, match_ranges_for, ByteSpan, SearchRealm};

Expand All @@ -28,6 +29,8 @@ pub(crate) struct SearchEvent {
pub host_realms: Vec<SearchRealm>,
pub text_matches: Vec<TextMatch>,
pub matches: SearchEventMatches,
pub has_password: bool,
pub user_is_authorized: bool,
}

#[derive(Debug, GraphQLObject, Default)]
Expand Down Expand Up @@ -65,39 +68,46 @@ impl Node for SearchEvent {
}

impl SearchEvent {
pub(crate) fn without_matches(src: search::Event) -> Self {
Self::new_inner(src, vec![], SearchEventMatches::default())
pub(crate) fn without_matches(src: search::Event, context: &Context) -> Self {
let read_roles = decode_acl(&src.read_roles);
let user_can_read = context.auth.overlaps_roles(read_roles);
Self::new_inner(src, vec![], SearchEventMatches::default(), user_can_read)
}

pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>) -> Self {
pub(crate) fn new(hit: meilisearch_sdk::SearchResult<search::Event>, context: &Context) -> Self {
let match_positions = hit.matches_position.as_ref();
let src = hit.result;

let mut text_matches = Vec::new();
src.slide_texts.resolve_matches(
match_ranges_for(match_positions, "slide_texts.texts"),
&mut text_matches,
TextAssetType::SlideText,
);
src.caption_texts.resolve_matches(
match_ranges_for(match_positions, "caption_texts.texts"),
&mut text_matches,
TextAssetType::Caption,
);
let read_roles = decode_acl(&src.read_roles);
let user_can_read = context.auth.overlaps_roles(read_roles);
if user_can_read {
src.slide_texts.resolve_matches(
match_ranges_for(match_positions, "slide_texts.texts"),
&mut text_matches,
TextAssetType::SlideText,
);
src.caption_texts.resolve_matches(
match_ranges_for(match_positions, "caption_texts.texts"),
&mut text_matches,
TextAssetType::Caption,
);
}

let matches = SearchEventMatches {
title: field_matches_for(match_positions, "title"),
description: field_matches_for(match_positions, "description"),
series_title: field_matches_for(match_positions, "series_title"),
};

Self::new_inner(src, text_matches, matches)
Self::new_inner(src, text_matches, matches, user_can_read)
}

fn new_inner(
src: search::Event,
text_matches: Vec<TextMatch>,
matches: SearchEventMatches,
user_can_read: bool,
) -> Self {
Self {
id: Id::search_event(src.id.0),
Expand All @@ -118,6 +128,8 @@ impl SearchEvent {
.collect(),
text_matches,
matches,
has_password: src.has_password,
user_is_authorized: user_can_read,
}
}
}
Loading
Loading