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 config to lock video ACLs to their series #1305

Merged
merged 3 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ impl AuthorizedEvent {
synced_data: None,
created: None,
metadata: None,
read_roles: vec![],
write_roles: vec![],
}))
} else {
// We need to load the series as fields were requested that were not preloaded.
Expand Down
33 changes: 31 additions & 2 deletions backend/src/api/model/series.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ use crate::{
api::{
Context, Id, Node, NodeValue,
err::{invalid_input, ApiResult},
model::{event::AuthorizedEvent, realm::Realm},
model::{
event::AuthorizedEvent,
realm::Realm,
acl::{self, Acl},
},
},
db::{types::{ExtraMetadata, Key, SeriesState as State}, util::impl_from_db},
prelude::*,
Expand All @@ -26,6 +30,8 @@ pub(crate) struct Series {
pub(crate) title: String,
pub(crate) created: Option<DateTime<Utc>>,
pub(crate) metadata: Option<ExtraMetadata>,
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,
}

#[derive(GraphQLObject)]
Expand All @@ -36,7 +42,11 @@ pub(crate) struct SyncedSeriesData {
impl_from_db!(
Series,
select: {
series.{ id, opencast_id, state, title, description, created, metadata },
series.{
id, opencast_id, state,
title, description, created,
metadata, read_roles, write_roles,
},
},
|row| {
Series {
Expand All @@ -45,6 +55,8 @@ impl_from_db!(
title: row.title(),
created: row.created(),
metadata: row.metadata(),
read_roles: row.read_roles(),
write_roles: row.write_roles(),
synced_data: (State::Ready == row.state()).then(
|| SyncedSeriesData {
description: row.description(),
Expand Down Expand Up @@ -85,6 +97,19 @@ impl Series {
.pipe(Ok)
}

async fn load_acl(&self, context: &Context) -> ApiResult<Acl> {
let raw_roles_sql = "\
select unnest($1::text[]) as role, 'read' as action
union
select unnest($2::text[]) as role, 'write' as action
";

acl::load_for(context, raw_roles_sql, dbargs![
&self.read_roles,
&self.write_roles,
]).await
}

pub(crate) async fn create(series: NewSeries, context: &Context) -> ApiResult<Self> {
let selection = Self::select();
let query = format!(
Expand Down Expand Up @@ -269,6 +294,10 @@ impl Series {
&self.synced_data
}

async fn acl(&self, context: &Context) -> ApiResult<Acl> {
self.load_acl(context).await
}

async fn host_realms(&self, context: &Context) -> ApiResult<Vec<Realm>> {
let selection = Realm::select();
let query = format!("\
Expand Down
5 changes: 5 additions & 0 deletions backend/src/config/general.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ pub(crate) struct GeneralConfig {
/// or takes an unusually long time to complete.
#[config(default = true)]
pub allow_acl_edit: bool,

/// Activating this will disable ACL editing for events that are part of a series.
/// For the uploader, this means that the ACL of the series will be used.
#[config(default = false)]
pub lock_acl_to_series: bool,
}

const INTERNAL_RESERVED_PATHS: &[&str] = &["favicon.ico", "robots.txt", ".well-known"];
Expand Down
1 change: 1 addition & 0 deletions backend/src/http/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ fn frontend_config(config: &Config) -> serde_json::Value {
"showDownloadButton": config.general.show_download_button,
"usersSearchable": config.general.users_searchable,
"allowAclEdit": config.general.allow_acl_edit,
"lockAclToSeries": config.general.lock_acl_to_series,
"footerLinks": config.general.footer_links,
"metadataLabels": config.general.metadata,
"paellaPluginConfig": config.player.paella_plugin_config,
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/setup/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@
# Default value: true
#allow_acl_edit = true

# Activating this will disable ACL editing for events that are part of a series.
# For the uploader, this means that the ACL of the series will be used.
#
# Default value: false
#lock_acl_to_series = false


[db]
# The username of the database user.
Expand Down
1 change: 1 addition & 0 deletions frontend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Config = {
showDownloadButton: boolean;
usersSearchable: boolean;
allowAclEdit: boolean;
lockAclToSeries: boolean;
opencast: OpencastConfig;
footerLinks: FooterLink[];
metadataLabels: Record<string, Record<string, MetadataLabel>>;
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/i18n/locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ user:
manage-content: Verwalten

login-page:
heading: Anmeldung
heading: Anmeldung
user-id: Nutzerkennung
password: Passwort
bad-credentials: 'Anmeldung fehlgeschlagen: Falsche Anmeldedaten.'
Expand Down Expand Up @@ -321,6 +321,7 @@ upload:
opencast-server-error: Opencast-Server-Fehler (unerwartete Antwort).
opencast-unreachable: 'Netzwerkfehler: Opencast kann nicht erreicht werden.'
jwt-invalid: 'Interner Fremdauthentifizierungsfehler: Opencast hat das Hochladen nicht autorisiert.'
failed-fetching-series-acl: Abruf der Serienzugangsberechtigungen fehlgeschlagen.

acl:
unknown-user-note: unbekannt
Expand All @@ -341,6 +342,8 @@ manage:
Änderung der Berechtigungen ist zurzeit nicht möglich, da das Video im Hintergrund verarbeitet wird.
<br />
Bitte versuchen Sie es später nochmal.
locked-to-series: >
Die Berechtigungen dieses Videos werden durch seine Serie bestimmt und können daher nicht bearbeitet werden.
users-no-options:
initial-searchable: Nach Name suchen oder exakten Nutzernamen/exakte E-Mail angeben
none-found-searchable: Keine Personen gefunden
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ upload:
opencast-server-error: Opencast server error (unexpected response).
opencast-unreachable: 'Network error: Opencast cannot be reached.'
jwt-invalid: 'Internal cross-authentication error: Opencast did not authorize the upload.'
failed-fetching-series-acl: Failed to fetch series acl.

acl:
unknown-user-note: unknown
Expand All @@ -338,6 +339,8 @@ manage:
Changing the access policy is not possible at this time, since the video is being processed in the background.
<br />
Please try again later.
locked-to-series: >
The access policy of this video is determined by its series and can't be edited.
users-no-options:
initial-searchable: Type to search for users by name (or enter exact email/username)
none-found-searchable: No user found
Expand Down
120 changes: 100 additions & 20 deletions frontend/src/routes/Upload.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import React, { MutableRefObject, ReactNode, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { fetchQuery, graphql, useFragment } from "react-relay";
import { keyframes } from "@emotion/react";
import { Controller, useController, useForm } from "react-hook-form";
import { LuCheckCircle, LuUpload, LuInfo } from "react-icons/lu";
import { WithTooltip, assertNever, bug, unreachable } from "@opencast/appkit";
import { Spinner, WithTooltip, assertNever, bug, unreachable } from "@opencast/appkit";

import { RootLoader } from "../layout/Root";
import { loadQuery } from "../relay";
import { environment, loadQuery } from "../relay";
import { UploadQuery } from "./__generated__/UploadQuery.graphql";
import { makeRoute } from "../rauta";
import { ErrorDisplay, errorDisplayInfo } from "../util/err";
import { useNavBlocker } from "./util";
import { mapAcl, useNavBlocker } from "./util";
import CONFIG from "../config";
import { Button, boxError, ErrorBox, Card } from "@opencast/appkit";
import { LinkButton } from "../ui/LinkButton";
Expand All @@ -34,6 +34,10 @@ import {
AccessKnownRolesData$key,
} from "../ui/__generated__/AccessKnownRolesData.graphql";
import { READ_WRITE_ACTIONS } from "../util/permissionLevels";
import {
UploadSeriesAclQuery,
UploadSeriesAclQuery$data,
} from "./__generated__/UploadSeriesAclQuery.graphql";


const PATH = "/~manage/upload" as const;
Expand Down Expand Up @@ -64,11 +68,14 @@ const query = graphql`
}
`;


export type AclArray = NonNullable<UploadSeriesAclQuery$data["series"]>["acl"];
type Metadata = {
title: string;
description: string;
series?: string;
series?: {
id: string;
acl: AclArray;
};
acl: Acl;
};

Expand Down Expand Up @@ -663,6 +670,14 @@ const ProgressBar: React.FC<ProgressBarProps> = ({ state }) => {
};


const SeriesAclQuery = graphql`
query UploadSeriesAclQuery($seriesId: String!) {
series: seriesByOpencastId(id: $seriesId) {
acl { role actions info { label implies large } }
}
}
`;
LukasKalbertodt marked this conversation as resolved.
Show resolved Hide resolved

type MetaDataEditProps = {
onSave: (metadata: Metadata) => void;
disabled: boolean;
Expand All @@ -680,6 +695,56 @@ const MetaDataEdit: React.FC<MetaDataEditProps> = ({ onSave, disabled, knownRole
const titleFieldId = useId();
const descriptionFieldId = useId();
const seriesFieldId = useId();
const [lockedAcl, setLockedAcl] = useState<Acl | null>(null);
const [aclError, setAclError] = useState<ReactNode>(null);
const [aclLoading, setAclLoading] = useState(false);
const aclEditingLocked = !!lockedAcl || aclLoading || !!aclError;

const fetchSeriesAcl = async (seriesId: string): Promise<Acl | null> => {
const data = await fetchQuery<UploadSeriesAclQuery>(
environment,
SeriesAclQuery,
{ seriesId }
).toPromise();
Comment on lines +704 to +708
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think useQueryLoader is more idiomatic here? :/ But mhhh not sure if it's worth it changing that. It would also change the UX a bit: the ACL UI would be completely hidden while loading instead of showing the old value. Not sure if thats better or worse. So yeah no hard opinion, we can probably just keep it...


if (!data?.series?.acl) {
return null;
}

return mapAcl(data.series.acl);
};

const onSeriesChange = async (data: { opencastId?: string }) => {
setAclError(null);

if (!data?.opencastId) {
setLockedAcl(null);
return;
}

seriesField.onChange({ id: data.opencastId });

if (CONFIG.lockAclToSeries) {
setAclLoading(true);
try {
const seriesAcl = await fetchSeriesAcl(data.opencastId);
setLockedAcl(seriesAcl);
seriesField.onChange({
id: data.opencastId,
acl: seriesAcl,
});
} catch (e) {
setAclError(
<ErrorDisplay
error={e}
failedAction={t("upload.errors.failed-fetching-series-acl")}
/>
);
} finally {
setAclLoading(false);
}
}
};

const defaultAcl: Acl = new Map([
[user.userRole, {
Expand Down Expand Up @@ -770,7 +835,7 @@ const MetaDataEdit: React.FC<MetaDataEditProps> = ({ onSave, disabled, knownRole
inputId={seriesFieldId}
writableOnly
menuPlacement="top"
onChange={data => seriesField.onChange(data?.opencastId)}
onChange={data => onSeriesChange({ opencastId: data?.opencastId })}
onBlur={seriesField.onBlur}
required={CONFIG.upload.requireSeries}
/>
Expand All @@ -784,17 +849,32 @@ const MetaDataEdit: React.FC<MetaDataEditProps> = ({ onSave, disabled, knownRole
marginBottom: 12,
fontSize: 22,
}}>{t("manage.my-videos.acl.title")}</h2>
<Controller
name="acl"
control={control}
render={({ field }) => <AclSelector
userIsRequired
onChange={field.onChange}
acl={field.value}
knownRoles={knownRoles}
permissionLevels={READ_WRITE_ACTIONS}
/>}
/>
{boxError(aclError)}
{aclLoading && <Spinner size={20} />}
{lockedAcl && (
<Card kind="info" iconPos="left" css={{
maxWidth: 700,
fontSize: 14,
marginBottom: 10,
}}>
{t("manage.access.locked-to-series")}
</Card>
)}
<div {...aclEditingLocked && { inert: "true" }} css={{
...aclEditingLocked && { opacity: .7 },
}}>
<Controller
name="acl"
control={control}
render={({ field }) => <AclSelector
userIsRequired
onChange={field.onChange}
acl={lockedAcl ?? field.value}
knownRoles={knownRoles}
permissionLevels={READ_WRITE_ACTIONS}
/>}
/>
</div>
</InputContainer>

{/* Submit button */}
Expand Down Expand Up @@ -1039,7 +1119,7 @@ const finishUpload = async (
}

// Add ACL
{
if (!CONFIG.lockAclToSeries) {
const acl = constructAcl(metadata.acl);
const body = new FormData();
body.append("flavor", "security/xacml+episode");
Expand Down Expand Up @@ -1088,7 +1168,7 @@ const constructDcc = (metadata: Metadata, user: User): string => {
</dcterms:created>
${tag("dcterms:title", metadata.title)}
${tag("dcterms:description", metadata.description)}
${tag("dcterms:isPartOf", metadata.series)}
${tag("dcterms:isPartOf", metadata.series?.id)}
${tag("dcterms:creator", user.displayName)}
${tag("dcterms:spatial", "Tobira Upload")}
</dublincore>
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/routes/manage/Realm/RealmPermissions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { boxError } from "@opencast/appkit";
import { displayCommitError } from "./util";
import { currentRef } from "../../../util";
import { MODERATE_ADMIN_ACTIONS } from "../../../util/permissionLevels";
import { mapAcl } from "../../util";


const fragment = graphql`
Expand All @@ -36,12 +37,7 @@ export const RealmPermissions: React.FC<Props> = ({ fragRef, data }) => {
const ownerDisplayName = (realm.ancestors[0] ?? realm).ownerDisplayName;
const saveModalRef = useRef<ConfirmationModalHandle>(null);

const [initialAcl, inheritedAcl]: Acl[] = [realm.ownAcl, realm.inheritedAcl].map(acl => new Map(
acl.map(item => [item.role, {
actions: new Set(item.actions),
info: item.info,
}])
));
const [initialAcl, inheritedAcl] = [realm.ownAcl, realm.inheritedAcl].map(mapAcl);

const [selections, setSelections] = useState<Acl>(initialAcl);

Expand Down
Loading
Loading