-
Notifications
You must be signed in to change notification settings - Fork 18
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
Make event ACL editable via Tobira #1272
base: next
Are you sure you want to change the base?
Changes from all commits
aeab1a3
c5917a3
86045de
ed002c3
79c92e3
beead64
764449c
cce8143
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,6 +19,8 @@ use crate::{ | |
prelude::*, | ||
}; | ||
|
||
use self::{acl::AclInput, err::ApiError}; | ||
|
||
use super::playlist::VideoListEntry; | ||
|
||
|
||
|
@@ -212,6 +214,11 @@ impl AuthorizedEvent { | |
&self.tobira_deletion_timestamp | ||
} | ||
|
||
/// Whether the event has active workflows. | ||
async fn has_active_workflows(&self, context: &Context) -> ApiResult<bool> { | ||
Self::has_active_workflows(&self, context).await | ||
} | ||
|
||
async fn series(&self, context: &Context) -> ApiResult<Option<Series>> { | ||
if let Some(series) = self.series { | ||
Ok(Series::load_by_key(series, context).await?) | ||
|
@@ -337,21 +344,37 @@ impl AuthorizedEvent { | |
.pipe(Ok) | ||
} | ||
|
||
pub(crate) async fn delete(id: Id, context: &Context) -> ApiResult<RemovedEvent> { | ||
async fn load_for_api( | ||
id: Id, | ||
context: &Context, | ||
not_found_error: ApiError, | ||
not_authorized_error: ApiError, | ||
) -> ApiResult<AuthorizedEvent> { | ||
let event = Self::load_by_id(id, context) | ||
.await? | ||
.ok_or_else(|| err::invalid_input!( | ||
key = "event.delete.not-found", | ||
"event not found", | ||
))? | ||
.await? | ||
.ok_or_else(|| not_found_error)? | ||
.into_result()?; | ||
|
||
if !context.auth.overlaps_roles(&event.write_roles) { | ||
return Err(err::not_authorized!( | ||
return Err(not_authorized_error); | ||
} | ||
|
||
Ok(event) | ||
} | ||
|
||
pub(crate) async fn delete(id: Id, context: &Context) -> ApiResult<RemovedEvent> { | ||
let event = Self::load_for_api( | ||
id, | ||
context, | ||
err::invalid_input!( | ||
key = "event.delete.not-found", | ||
"event not found" | ||
), | ||
err::not_authorized!( | ||
key = "event.delete.not-allowed", | ||
"you are not allowed to delete this event", | ||
)); | ||
} | ||
) | ||
).await?; | ||
|
||
let response = context | ||
.oc_client | ||
|
@@ -381,6 +404,120 @@ impl AuthorizedEvent { | |
} | ||
} | ||
|
||
async fn has_active_workflows(&self, context: &Context) -> ApiResult<bool> { | ||
if !context.auth.overlaps_roles(&self.write_roles) { | ||
return Err(err::not_authorized!( | ||
key = "event.workflow.not-allowed", | ||
"you are not allowed to inquire about this event's workflow activity", | ||
)); | ||
} | ||
|
||
let response = context | ||
.oc_client | ||
.has_active_workflows(&self.opencast_id) | ||
.await | ||
.map_err(|e| { | ||
error!("Failed to get workflow activity: {}", e); | ||
err::opencast_unavailable!("Failed to communicate with Opencast") | ||
})?; | ||
|
||
Ok(response) | ||
} | ||
|
||
pub(crate) async fn update_acl(id: Id, acl: Vec<AclInput>, context: &Context) -> ApiResult<AuthorizedEvent> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Without having looked at what the frontend is actually sending, there is a problem here:
So for one, we have to decide what this API requires (just read&write or full ACL) and specify this in the API docs. As second step, this API handler needs to be adjusted to either add the other actions to the OC request, or to write all actions, not just read and write, to our DB. |
||
let event = Self::load_for_api( | ||
id, | ||
context, | ||
err::invalid_input!( | ||
key = "event.acl.not-found", | ||
"event not found", | ||
), | ||
err::not_authorized!( | ||
key = "event.acl.not-allowed", | ||
"you are not allowed to update this event's acl", | ||
) | ||
).await?; | ||
|
||
if Self::has_active_workflows(&event, context).await? { | ||
return Err(err::not_authorized!( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think |
||
key = "event.workflow.active", | ||
"acl change blocked by another workflow", | ||
)); | ||
} | ||
|
||
let response = context | ||
.oc_client | ||
.update_event_acl(&event.opencast_id, &acl) | ||
.await | ||
.map_err(|e| { | ||
error!("Failed to send acl update request: {}", e); | ||
err::opencast_unavailable!("Failed to communicate with Opencast") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also put "Failed to send acl update request" in the second line? It's a more specific error |
||
})?; | ||
|
||
if response.status() == StatusCode::NO_CONTENT { | ||
// 204: The access control list for the specified event is updated. | ||
let roles_for_action = |target_action: &str| -> Vec<String> { | ||
acl.iter() | ||
.filter(|entry| entry.allow && entry.action == target_action) | ||
.map(|entry| entry.role.clone()) | ||
.collect() | ||
}; | ||
let read_roles = roles_for_action("read"); | ||
let write_roles = roles_for_action("write"); | ||
Comment on lines
+459
to
+466
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about let mut map = HashMap::new();
for e in acl {
map.entry(entry.action).or_insert(vec![]).push(entry.role);
}
let read_roles = map.get("read").unwrap_or_default();
let write_roles = map.get("write").unwrap_or_default(); Mh ok, just 2 lines shorter, but it at least only loops through the thing once. But eh, probably whatever. |
||
info!(event_id = %id, "Requested acl update of event"); | ||
|
||
// Todo: also update preview roles | ||
context.db.execute("\ | ||
update all_events \ | ||
set read_roles = $2, write_roles = $3 \ | ||
where id = $1 \ | ||
", &[&event.key, &read_roles, &write_roles]).await?; | ||
|
||
Self::start_workflow(&event.opencast_id, "republish-metadata", &context).await?; | ||
Comment on lines
+475
to
+476
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That should probably be called before the DB update? My thinking is: if starting the workflow fails, the event ACL in Opencast is never republished and thus never actually "public" and keeping the old ACL in our DB is more correct as it would reflect the state of the harvest API. If the DB change fails after starting the workflow, thats not too big of a deal as it will be updated via sync a few minutes later anyway. But sure, the workflow could still fail after being started, which is what we discussed a couple of times and decided to ignore that. So dealing with "starting workflow failed" is likely not too important. |
||
Ok(event) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You return the EDIT: ah mh, so the |
||
} else { | ||
warn!( | ||
event_id = %id, | ||
"Failed to update event acl, OC returned status: {}", | ||
response.status(), | ||
); | ||
Err(err::opencast_unavailable!("Opencast API error: {}", response.status())) | ||
} | ||
} | ||
|
||
/// Starts a workflow on the event. | ||
async fn start_workflow(oc_id: &String, workflow_id: &str, context: &Context) -> ApiResult<StatusCode> { | ||
let response = context | ||
.oc_client | ||
.start_workflow(&oc_id, "republish-metadata") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Woops, you surely meant to pass |
||
.await | ||
.map_err(|e| { | ||
error!("Failed sending request to start workflow: {}", e); | ||
err::opencast_unavailable!("Failed to communicate with Opencast") | ||
})?; | ||
|
||
if response.status() == StatusCode::CREATED { | ||
// 201: A new workflow is created. | ||
info!(workflow = %workflow_id, "Requested creation of workflow"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
Ok(response.status()) | ||
} else if response.status() == StatusCode::NOT_FOUND { | ||
// 404: The specified workflow instance does not exist. | ||
warn!( | ||
workflow = %workflow_id, | ||
"{}: The specified workflow instance does not exist.", | ||
response.status(), | ||
Comment on lines
+507
to
+508
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In this branch |
||
); | ||
Err(err::opencast_unavailable!("Opencast API error: {}", response.status())) | ||
} else { | ||
warn!( | ||
workflow = %workflow_id, | ||
"Failed to create workflow, OC returned status: {}", | ||
response.status(), | ||
); | ||
Err(err::opencast_unavailable!("Opencast API error: {}", response.status())) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mh |
||
} | ||
} | ||
|
||
pub(crate) async fn load_writable_for_user( | ||
context: &Context, | ||
order: EventSortOrder, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,7 @@ use super::{ | |
err::ApiResult, | ||
id::Id, | ||
model::{ | ||
acl::AclInput, | ||
series::{Series, NewSeries}, | ||
realm::{ | ||
ChildIndex, | ||
|
@@ -58,7 +59,7 @@ impl Mutation { | |
/// Deletes the given event. Meaning: a deletion request is sent to Opencast, the event | ||
/// is marked as "deletion pending" in Tobira, and fully removed once Opencast | ||
/// finished deleting the event. | ||
/// | ||
/// | ||
/// Returns the deletion timestamp in case of success and errors otherwise. | ||
/// Note that "success" in this case only means the request was successfully sent | ||
/// and accepted, not that the deletion itself succeeded, which is instead checked | ||
|
@@ -67,6 +68,15 @@ impl Mutation { | |
AuthorizedEvent::delete(id, context).await | ||
} | ||
|
||
/// Updates the acl of a given event by sending a PUT request to Opencast. If the request is | ||
/// successful (as indicated by the response code 204), the updated acl is already 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. | ||
/// This solution should be improved in the future. | ||
Comment on lines
+71
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like the "PUT" and "204" parts are overly specific for API documentation and just implementation detail. Maybe "... by sending the changes to Opencast. If successful, the updated ACL are stored in Tobira..." |
||
async fn update_event_acl(id: Id, acl: Vec<AclInput>, context: &Context) -> ApiResult<AuthorizedEvent> { | ||
AuthorizedEvent::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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ use serde::Deserialize; | |
use tap::TapFallible; | ||
|
||
use crate::{ | ||
api::model::acl::AclInput, | ||
config::{Config, HttpHost}, | ||
prelude::*, | ||
sync::harvest::HarvestResponse, | ||
|
@@ -140,7 +141,7 @@ impl OcClient { | |
} | ||
|
||
pub async fn delete_event(&self, oc_id: &String) -> Result<Response<Incoming>> { | ||
let pq = format!("/api/events/{}", oc_id); | ||
let pq = format!("/api/events/{oc_id}"); | ||
let req = self.authed_req_builder(&self.external_api_node, &pq) | ||
.method(http::Method::DELETE) | ||
.body(RequestBody::empty()) | ||
|
@@ -149,6 +150,48 @@ impl OcClient { | |
self.http_client.request(req).await.map_err(Into::into) | ||
} | ||
|
||
pub async fn update_event_acl(&self, oc_id: &String, acl: &Vec<AclInput>) -> Result<Response<Incoming>> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Basically always use |
||
let pq = format!("/api/events/{oc_id}/acl"); | ||
let req = self.authed_req_builder(&self.external_api_node, &pq) | ||
.method(http::Method::PUT) | ||
.header(http::header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
.body( | ||
format!("acl={}", serde_json::to_string(&acl).expect("Failed to serialize")).into() | ||
) | ||
Comment on lines
+157
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like it is missing some escaping. We already have |
||
.expect("failed to build request"); | ||
|
||
self.http_client.request(req).await.map_err(Into::into) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this could use an |
||
} | ||
|
||
pub async fn start_workflow(&self, oc_id: &String, workflow_id: &str) -> Result<Response<Incoming>> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
let params = format!("\ | ||
event_identifier={oc_id}\ | ||
&workflow_definition_identifier={workflow_id}\ | ||
"); | ||
Comment on lines
+167
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here again, the two variables might need escaping/proper encoding. |
||
let req = self.authed_req_builder(&self.external_api_node, "/api/workflows") | ||
.method(http::Method::POST) | ||
.header(http::header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
.body(params.into()) | ||
.expect("failed to build request"); | ||
|
||
self.http_client.request(req).await.map_err(Into::into) | ||
} | ||
|
||
pub async fn has_active_workflows(&self, oc_id: &String) -> Result<bool> { | ||
let pq = format!("/workflow/mediaPackage/{oc_id}/hasActiveWorkflows"); | ||
let req = self.authed_req_builder(&self.external_api_node, &pq) | ||
Comment on lines
+181
to
+182
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mh so, this is not using the "external API" anymore (those paths would start with Edit: Ok so it seems like this API is always available on the admin node. So we can't really use The other thing is that the stability of this API is not guaranteed too much. I'm sure lots of other apps use it, but maybe Tobira should be able to deal with the case that the API doesn't exist? Maybe in that case just don't block editing ACL? Mhhh |
||
.header(http::header::CONTENT_TYPE, "application/x-www-form-urlencoded") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this header necessary? |
||
.body(RequestBody::empty()) | ||
.expect("failed to build request"); | ||
let uri = req.uri().clone(); | ||
let response = self.http_client.request(req) | ||
.await | ||
.with_context(|| format!("HTTP request failed (uri: '{uri}')"))?; | ||
|
||
let (out, _) = self.deserialize_response(response, &uri).await?; | ||
Ok(out) | ||
} | ||
|
||
fn build_authed_req(&self, node: &HttpHost, path_and_query: &str) -> (Uri, Request<RequestBody>) { | ||
let req = self.authed_req_builder(node, path_and_query) | ||
.body(RequestBody::empty()) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This
allow
flag does not need to be in our API, right? I know it's something Opencast expect in its API, but we never set it tofalse
, so it doesn't make sense to send a constant between frontend and backend. This flag should be added at the latest possible state, just to make the OC API happy.I also noticed that this "format" of ACL is not native to either the backend or frontend, meaning: the frontend has a loop to convert "its format" to the API format, and the backend has the same to convert it from API format to a format that we can pass to the DB.
We already have ACLs in our API at one other point:
AclItem
. And there each item is{ role: string, actions: string[], info: ... }
. Theinfo
part we could ignore, but what if we changeAclInput
to:Then it's closer to what we already have in the API and the frontend conversion would be easier, as its closer to its "native" format. Might make the backend conversion more complicated. Ah I don't know, this second point is probably not too important...