Skip to content

Commit

Permalink
Add series ACL page
Browse files Browse the repository at this point in the history
This required a bunch of changes:
- The `update_acl` endpoint which talks to Opencast
  was generalized to work for both events and series,
  as their acl `put` endpoints pretty much work the same.
- The access page of events was refactored and most code
  is now usable for both events and series. This tries to
  walk the thin line between modularity and overspecialization
  by attempting to balance out reusablility and complexity,
  limiting both duplicated code and prop drilling.
  • Loading branch information
owi92 committed Jan 27, 2025
1 parent a6e588f commit 0380207
Show file tree
Hide file tree
Showing 17 changed files with 423 additions and 192 deletions.
87 changes: 33 additions & 54 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use std::collections::HashSet;

use chrono::{DateTime, Utc};
use hyper::StatusCode;
use postgres_types::ToSql;
Expand All @@ -14,18 +12,20 @@ use crate::{
acl::{self, Acl},
realm::Realm,
series::Series,
shared::convert_acl_input,
},
Context,
Id,
Node,
NodeValue,
},
db::{
types::{EventCaption, EventSegment, EventState, EventTrack, Credentials},
types::{Credentials, EventCaption, EventSegment, EventState, EventTrack},
util::{impl_from_db, select},
},
model::{Key, ExtraMetadata},
model::{ExtraMetadata, Key},
prelude::*,
sync::client::{AclInput, OcEndpoint}
};

use self::{acl::AclInputEntry, err::ApiError};
Expand Down Expand Up @@ -579,7 +579,7 @@ impl AuthorizedEvent {

let response = context
.oc_client
.update_event_acl(&event.opencast_id, &acl, context)
.update_acl(&event, &event.opencast_id, &acl, context)
.await
.map_err(|e| {
error!("Failed to send acl update request: {}", e);
Expand Down Expand Up @@ -674,6 +674,34 @@ impl LoadableAsset for AuthorizedEvent {
}
}

impl OcEndpoint for AuthorizedEvent {
fn endpoint_name(&self) -> &'static str {
"events"
}

async fn extra_roles(&self, context: &Context, oc_id: &str) -> Result<Vec<AclInput>> {
let query = "\
select unnest(preview_roles) as role, 'preview' as action from events where opencast_id = $1
union
select role, key as action
from jsonb_each_text(
(select custom_action_roles from events where opencast_id = $1)
) as actions(key, value)
cross join lateral jsonb_array_elements_text(value::jsonb) as role(role)
";

context.db.query_mapped(&query, dbargs![&oc_id], |row| {
let role: String = row.get("role");
let action: String = row.get("action");
AclInput {
allow: true,
action,
role,
}
}).await.map_err(Into::into)
}
}

impl From<EventTrack> for Track {
fn from(src: EventTrack) -> Self {
Self {
Expand Down Expand Up @@ -726,52 +754,3 @@ impl EventConnection {
pub(crate) struct RemovedEvent {
id: Id,
}

#[derive(Debug)]
struct AclForDB {
// todo: add custom and preview roles when sent by frontend
// preview_roles: Vec<String>,
read_roles: Vec<String>,
write_roles: Vec<String>,
// custom_action_roles: CustomActions,
}

fn convert_acl_input(entries: Vec<AclInputEntry>) -> AclForDB {
// let mut preview_roles = HashSet::new();
let mut read_roles = HashSet::new();
let mut write_roles = HashSet::new();
// let mut custom_action_roles = CustomActions::default();

for entry in entries {
let role = entry.role;
for action in entry.actions {
match action.as_str() {
// "preview" => {
// preview_roles.insert(role.clone());
// }
"read" => {
read_roles.insert(role.clone());
}
"write" => {
write_roles.insert(role.clone());
}
_ => {
// custom_action_roles
// .0
// .entry(action)
// .or_insert_with(Vec::new)
// .push(role.clone());
todo!();
}
};
}
}

AclForDB {
// todo: add custom and preview roles when sent by frontend
// preview_roles: preview_roles.into_iter().collect(),
read_roles: read_roles.into_iter().collect(),
write_roles: write_roles.into_iter().collect(),
// custom_action_roles,
}
}
77 changes: 73 additions & 4 deletions backend/src/api/model/series.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
use chrono::{DateTime, Utc};
use hyper::StatusCode;
use juniper::{graphql_object, GraphQLObject, GraphQLInputObject};
use postgres_types::ToSql;

use crate::{
api::{
Context, Id, Node, NodeValue,
err::{invalid_input, ApiResult},
err::{self, invalid_input, ApiResult},
model::{
acl::{self, Acl},
event::AuthorizedEvent,
realm::Realm,
acl::{self, Acl},
shared::convert_acl_input
},
},
db::{
types::SeriesState as State,
util::{impl_from_db, select},
},
model::{Key, ExtraMetadata},
prelude::*,
model::{ExtraMetadata, Key},
prelude::*, sync::client::{AclInput, OcEndpoint},
};

use self::acl::AclInputEntry;

use super::{
block::{BlockValue, NewSeriesBlock, VideoListLayout, VideoListOrder},
playlist::VideoListEntry,
Expand Down Expand Up @@ -302,6 +306,60 @@ impl Series {

Ok(SeriesConnection { inner: conn })
}

pub(crate) async fn update_acl(id: Id, acl: Vec<AclInputEntry>, context: &Context) -> ApiResult<Series> {
if !context.config.general.allow_acl_edit {
return Err(err::not_authorized!("editing ACLs is not allowed"));
}

let series = Self::load_by_id(id, context)
.await?
.ok_or_else(|| invalid_input!("`seriesId` does not refer to a valid series"))?;

info!(series_id = %id, "Requesting ACL update of series");

let response = context
.oc_client
.update_acl(&series, &series.opencast_id, &acl, context)
.await
.map_err(|e| {
error!("Failed to send acl update request: {}", e);
err::opencast_unavailable!("Failed to send acl update request")
})?;

if response.status() == StatusCode::OK {
// 200: The updated access control list is returned.
let db_acl = convert_acl_input(acl);

context.db.execute("\
update series \
set read_roles = $2, write_roles = $3 \
where id = $1 \
", &[&series.key, &db_acl.read_roles, &db_acl.write_roles]).await?;

if context.config.general.lock_acl_to_series {
context.db.execute("\
update events \
set read_roles = $2, write_roles = $3 \
where series = $1 \
", &[&series.key, &db_acl.read_roles, &db_acl.write_roles]).await?;
}

Self::load_by_id(id, context)
.await?
.ok_or_else(|| err::invalid_input!(
key = "series.acl.not-found",
"series not found",
))
} else {
warn!(
series_id = %id,
"Failed to update series acl, OC returned status: {}",
response.status(),
);
Err(err::opencast_error!("Opencast API error: {}", response.status()))
}
}
}

/// Represents an Opencast series.
Expand Down Expand Up @@ -384,6 +442,17 @@ impl Node for Series {
}
}

impl OcEndpoint for Series {
fn endpoint_name(&self) -> &'static str {
"series"
}

async fn extra_roles(&self, _context: &Context, _oc_id: &str) -> Result<Vec<AclInput>> {
// Series do not have custom or preview roles.
Ok(vec![])
}
}


#[derive(GraphQLInputObject)]
pub(crate) struct NewSeries {
Expand Down
55 changes: 55 additions & 0 deletions backend/src/api/model/shared.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use std::collections::HashSet;

use tokio_postgres::types::ToSql;
use crate::api::err::{ApiResult, invalid_input};
use crate::api::Context;
use crate::{db, FromDb, HasRoles};
use juniper::{GraphQLEnum, GraphQLInputObject, GraphQLObject};

use super::acl::AclInputEntry;


#[derive(Debug, Clone, Copy)]
pub struct SortOrder<C> {
Expand Down Expand Up @@ -247,3 +251,54 @@ where
page_info,
})
}


#[derive(Debug)]
pub(crate) struct AclForDB {
// todo: add custom and preview roles for events when sent by frontend
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,
// preview_roles: Option<Vec<String>>,
// custom_action_roles: Option<CustomActions>,
}

pub(crate) fn convert_acl_input(entries: Vec<AclInputEntry>) -> AclForDB {
let mut read_roles = HashSet::new();
let mut write_roles = HashSet::new();
// let mut preview_roles = HashSet::new();
// let mut custom_action_roles = CustomActions::default();

for entry in entries {
let role = entry.role;
for action in entry.actions {
match action.as_str() {
// "preview" => {
// preview_roles.insert(role.clone());
// }
"read" => {
read_roles.insert(role.clone());
}
"write" => {
write_roles.insert(role.clone());
}
_ => {
// custom_action_roles
// .0
// .entry(action)
// .or_insert_with(Vec::new)
// .push(role.clone());
todo!();
}
};
}
}

AclForDB {
read_roles: read_roles.into_iter().collect(),
write_roles: write_roles.into_iter().collect(),
// todo: add custom and preview roles when sent by frontend
// preview_roles: preview_roles.into_iter().collect(),
// custom_action_roles,
}
}

8 changes: 8 additions & 0 deletions backend/src/api/mutation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ impl Mutation {
AuthorizedEvent::update_acl(id, acl, context).await
}

/// Updates the acl of a given series by sending the changes to Opencast.
/// The `acl` parameter can include `read` and `write` roles.
/// If successful, the updated ACL are stored in Tobira without waiting for an upcoming sync - however
/// this means it might get overwritten again if the update in Opencast failed for some reason.
async fn update_series_acl(id: Id, acl: Vec<AclInputEntry>, context: &Context) -> ApiResult<Series> {
Series::update_acl(id, acl, context).await
}

/// Sets the order of all children of a specific realm.
///
/// `childIndices` must contain at least one element, i.e. do not call this
Expand Down
42 changes: 15 additions & 27 deletions backend/src/sync/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,37 +151,20 @@ impl OcClient {
self.http_client.request(req).await.map_err(Into::into)
}

pub async fn update_event_acl(
pub async fn update_acl<T: OcEndpoint>(
&self,
endpoint: &T,
oc_id: &str,
acl: &[AclInputEntry],
context: &Context,
) -> Result<Response<Incoming>> {
let pq = format!("/api/events/{oc_id}/acl");
let endpoint_name = endpoint.endpoint_name();
let pq = format!("/api/{endpoint_name}/{oc_id}/acl", );
let mut access_policy = Vec::new();

// Temporary solution to add custom and preview roles
// Todo: remove again once frontend sends these roles.
let extra_roles_sql = "\
select unnest(preview_roles) as role, 'preview' as action from events where opencast_id = $1
union
select role, key as action
from jsonb_each_text(
(select custom_action_roles from events where opencast_id = $1)
) as actions(key, value)
cross join lateral jsonb_array_elements_text(value::jsonb) as role(role)
";

let extra_roles = context.db.query_mapped(&extra_roles_sql, dbargs![&oc_id], |row| {
let role: String = row.get("role");
let action: String = row.get("action");
AclInput {
allow: true,
action,
role,
}
}).await?;

let mut access_policy = Vec::new();
let extra_roles = endpoint.extra_roles(context, oc_id).await?;
access_policy.extend(extra_roles);

for entry in acl {
Expand Down Expand Up @@ -298,13 +281,18 @@ pub struct ExternalApiVersions {
}

#[derive(Debug, Serialize)]
struct AclInput {
allow: bool,
action: String,
role: String,
pub(crate) struct AclInput {
pub allow: bool,
pub action: String,
pub role: String,
}

#[derive(Debug, Deserialize)]
pub struct EventStatus {
pub processing_state: String,
}

pub(crate) trait OcEndpoint {
fn endpoint_name(&self) -> &'static str;
async fn extra_roles(&self, context: &Context, oc_id: &str) -> Result<Vec<AclInput>>;
}
2 changes: 1 addition & 1 deletion backend/src/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub(crate) mod cmd;
pub(crate) mod harvest;
pub(crate) mod stats;
pub(crate) mod text;
mod client;
pub(crate) mod client;
mod status;

pub(crate) use self::client::OcClient;
Expand Down
Loading

0 comments on commit 0380207

Please sign in to comment.