Skip to content

Commit

Permalink
Add config to lock video ACLs to their series (#1305)
Browse files Browse the repository at this point in the history
This will disable ACL editing for the uploader and the ACL UI when the
video is part of a series.
Uploaded videos will then adopt the ACL of that series.

This needs to be configured with `lock_acl_to_series`, the default for
this is `false`.

Closes #1006
  • Loading branch information
LukasKalbertodt authored Jan 20, 2025
2 parents 5337026 + b3a10b3 commit bc30d35
Show file tree
Hide file tree
Showing 14 changed files with 182 additions and 37 deletions.
2 changes: 2 additions & 0 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,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::{SeriesState as State}, util::impl_from_db},
model::{Key, ExtraMetadata},
Expand All @@ -27,6 +31,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 @@ -37,7 +43,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 @@ -46,6 +56,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 @@ -86,6 +98,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 @@ -270,6 +295,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 @@ -123,6 +123,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 } }
}
}
`;

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();

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

0 comments on commit bc30d35

Please sign in to comment.