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 ability to specify ACL in a concise way #1212

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
55 changes: 42 additions & 13 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,7 @@ further below for information on that.

# Defines which ACL to send when uploading the recording. See below for
# more information.
#acl = false
# -OR-
#acl = """
#<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
#<Policy ...>
# ...
#</Policy>
#"""
#acl = ...

# Defines a custom DC-Catalog (as a template). See below for more information.
#dcc = """
Expand Down Expand Up @@ -277,9 +270,10 @@ By default, this template is used:
With `upload.acl` you can configure which ACL is sent (as an attachment) to the Opencast server when uploading. Possible values:
- `true`: use the default ACL (this is the default behavior)
- `false`: do not send an ACL when uploading
- An object with string keys and string arrays as values, mapping from roles to list of allowed actions.
- A string containing a valid ACL XML template (note the `"""` multi line string in TOML)

The ACL XML template is a [Mustache.js template](https://mustache.github.io/mustache.5.html).
The ACL XML as well as each string in the object is a [Mustache.js template](https://mustache.github.io/mustache.5.html).
Summary of the template format: you can insert variables with `{{ foo }}`.
You can also access subfields like `{{ foo.bar }}` (if `foo` or `bar` is `null`, the expression just evaluates to the empty string).
Conditionals/loops work with the `{{ #foo }} ... {{ /foo }}` syntax:
Expand All @@ -297,11 +291,26 @@ Otherwise, processing will fail.

#### Examples

<details>
<summary>The default ACL template</summary>
##### The default ACL template

The default ACL template simply gives read and write access to `user.userRole`:

```toml
[upload]
acl = { "{{ user.userRole }}" = ["read", "write"] }
```

Or in alternative TOML syntax:

```toml
[upload]
acl."{{ user.userRole }}" = ["read", "write"]
```

Or as XML template:

<details>

```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Policy PolicyId="mediapackage-1"
Expand Down Expand Up @@ -353,14 +362,34 @@ The default ACL template simply gives read and write access to `user.userRole`:

</details>

##### Specifying via URL

To specify the ACL via URL parameter, it's likely not feasible to use XML.
You can use the object notation tho, by specifying a JSON object like this:

```
https://studio.opencast.org/?upload.acl={"ROLE_FOO":["read"],"{{user.userRole}}":["read","write"]}
```

Remember to percent encode the whole JSON object!

This is equivalent to the following TOML:

```toml
[upload]
acl.ROLE_FOO = "read"
acl."{{ user.userRole }}" = ["read", "write"]
```

<details>
<summary>Also giving access to LTI instructors</summary>

##### Also giving access to LTI instructors

This extends the default template and additionally gives read and write access to the LTI instructors (`{{ lti.context_id }}_Instructor`), *if* the user comes from an LTI session.
Note the use of conditionals `{{ #lti.context_id }} ... {{ /lti.context_id }}`.
If you also want to add read access for learners, just add another `<Rule>` with `{{ lti.context_id }}_Learner`.

<details>

```xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Policy PolicyId="mediapackage-1"
Expand Down
24 changes: 18 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"html-webpack-plugin": "^5.6.3",
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"is-plain-object": "^5.0.0",
"mustache": "^4.2.0",
"oscilloscope": "^1.3.0",
"react": "^18.3.1",
Expand Down
91 changes: 39 additions & 52 deletions src/opencast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Mustache from "mustache";
import { bug } from "@opencast/appkit";

import { recordingFileName, usePresentContext } from "./util";
import { Settings } from "./settings";
import { Acl, DEFAULT_ACL, Settings } from "./settings";
import { Recording } from "./studio-state";


Expand Down Expand Up @@ -513,9 +513,10 @@ export class Opencast {
mediaPackage: string;
uploadSettings: Settings["upload"];
}) {
const template = uploadSettings?.acl === true || (!uploadSettings?.acl)
? DEFAULT_ACL_TEMPLATE
: uploadSettings?.acl;
const aclConfig = uploadSettings?.acl;
const template = (aclConfig === true || !aclConfig)
? aclToXmlTemplate(DEFAULT_ACL)
: typeof aclConfig === "string" ? aclConfig : aclToXmlTemplate(aclConfig);
const acl = this.constructAcl(template);

const body = new FormData();
Expand Down Expand Up @@ -810,6 +811,40 @@ const renderTemplate = (template: string, view: object): string => {
return out;
};

const aclToXmlTemplate = (acl: Acl) => {
const rules = [...acl.entries()].flatMap(([role, actions], i) => actions.map((action, j) => `
<Rule RuleId="${i}:${j}" Effect="Permit">
<Target>
<Actions>
<Action>
<ActionMatch MatchId="urn:oasis:names:tc:xacml:1.0:function:string-equal">
<AttributeValue DataType="http://www.w3.org/2001/XMLSchema#string">${action}</AttributeValue>
<ActionAttributeDesignator AttributeId="urn:oasis:names:tc:xacml:1.0:action:action-id"
DataType="http://www.w3.org/2001/XMLSchema#string"/>
</ActionMatch>
</Action>
</Actions>
</Target>
<Condition>
<Apply FunctionId="urn:oasis:names:tc:xacml:1.0:function:string-is-in">
<AttributeValue DataType="http://www.w3.org/2001/XMLSchema#string">${role}</AttributeValue>
<SubjectAttributeDesignator AttributeId="urn:oasis:names:tc:xacml:2.0:subject:role"
DataType="http://www.w3.org/2001/XMLSchema#string"/>
</Apply>
</Condition>
</Rule>`
));

return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Policy PolicyId="mediapackage-1"
RuleCombiningAlgId="urn:oasis:names:tc:xacml:1.0:rule-combining-algorithm:permit-overrides"
Version="2.0"
xmlns="urn:oasis:names:tc:xacml:2.0:policy:schema:os">
${rules.join("\n")}
</Policy>
`;
};

const DEFAULT_DCC_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?>
<dublincore xmlns="http://www.opencastproject.org/xsd/1.0/dublincore/"
xmlns:dcterms="http://purl.org/dc/terms/"
Expand All @@ -825,54 +860,6 @@ const DEFAULT_DCC_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?>
</dublincore>
`;

const DEFAULT_ACL_TEMPLATE = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Policy PolicyId="mediapackage-1"
RuleCombiningAlgId="urn:oasis:names:tc:xacml:1.0:rule-combining-algorithm:permit-overrides"
Version="2.0"
xmlns="urn:oasis:names:tc:xacml:2.0:policy:schema:os">
<Rule RuleId="user_read_Permit" Effect="Permit">
<Target>
<Actions>
<Action>
<ActionMatch MatchId="urn:oasis:names:tc:xacml:1.0:function:string-equal">
<AttributeValue DataType="http://www.w3.org/2001/XMLSchema#string">read</AttributeValue>
<ActionAttributeDesignator AttributeId="urn:oasis:names:tc:xacml:1.0:action:action-id"
DataType="http://www.w3.org/2001/XMLSchema#string"/>
</ActionMatch>
</Action>
</Actions>
</Target>
<Condition>
<Apply FunctionId="urn:oasis:names:tc:xacml:1.0:function:string-is-in">
<AttributeValue DataType="http://www.w3.org/2001/XMLSchema#string">{{ user.userRole }}</AttributeValue>
<SubjectAttributeDesignator AttributeId="urn:oasis:names:tc:xacml:2.0:subject:role"
DataType="http://www.w3.org/2001/XMLSchema#string"/>
</Apply>
</Condition>
</Rule>
<Rule RuleId="user_write_Permit" Effect="Permit">
<Target>
<Actions>
<Action>
<ActionMatch MatchId="urn:oasis:names:tc:xacml:1.0:function:string-equal">
<AttributeValue DataType="http://www.w3.org/2001/XMLSchema#string">write</AttributeValue>
<ActionAttributeDesignator AttributeId="urn:oasis:names:tc:xacml:1.0:action:action-id"
DataType="http://www.w3.org/2001/XMLSchema#string"/>
</ActionMatch>
</Action>
</Actions>
</Target>
<Condition>
<Apply FunctionId="urn:oasis:names:tc:xacml:1.0:function:string-is-in">
<AttributeValue DataType="http://www.w3.org/2001/XMLSchema#string">{{ user.userRole }}</AttributeValue>
<SubjectAttributeDesignator AttributeId="urn:oasis:names:tc:xacml:2.0:subject:role"
DataType="http://www.w3.org/2001/XMLSchema#string"/>
</Apply>
</Condition>
</Rule>
</Policy>
`;

const smil = ({ start, end }: { start: number; end: number }) => `
<smil xmlns="http://www.w3.org/ns/SMIL">
<body>
Expand Down
45 changes: 38 additions & 7 deletions src/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { unreachable } from "@opencast/appkit";

import { decodeHexString, usePresentContext } from "./util";
import { DEFINES } from "./defines";
import { isPlainObject } from "is-plain-object";

const LOCAL_STORAGE_KEY = "ocStudioSettings";
const CONTEXT_SETTINGS_FILE = "settings.toml";
Expand All @@ -24,6 +25,13 @@ type SettingsSource = "src-server"| "src-url" | "src-local-storage";
const PRESENTER_SOURCES = ["opencast"] as const;
type PresenterSource = typeof PRESENTER_SOURCES[number];

/** Map from roles to array of permitted actions */
export type Acl = Map<string, string[]>;

export const DEFAULT_ACL: Acl = new Map([
["{{ user.userRole }}", ["read", "write"]],
]);

/** Opencast Studio runtime settings. */
export type Settings = {
opencast?: {
Expand All @@ -35,7 +43,7 @@ export type Settings = {
upload?: {
seriesId?: string;
workflowId?: string;
acl?: boolean | string;
acl?: boolean | string | Acl;
dcc?: string;
titleField?: FormFieldState;
presenterField?: FormFieldState;
Expand Down Expand Up @@ -380,8 +388,9 @@ const validate = (
const newValue = validation(value, allowParse, src);
return newValue === undefined ? value : newValue;
} catch (e) {
const printValue = typeof value === "object" ? JSON.stringify(value) : value;
console.warn(
`Validation of setting '${path}' (${sourceDescription}) with value '${value}' failed: `
`Validation of setting '${path}' (${sourceDescription}) with value '${printValue}' failed: `
+ `${e}. Ignoring.`
);
return null;
Expand Down Expand Up @@ -576,7 +585,27 @@ const SCHEMA = {
return v;
}

throw new Error("needs to be 'true', 'false' or an XML string");
let obj = v;
if (allowParse && typeof v === "string") {
const json = decodeURIComponent(v);
obj = JSON.parse(json);
}

if (typeof obj === "object" && obj) {
const out = new Map<string, string[]>();
for (const [key, value] of Object.entries(obj)) {
if (!Array.isArray(value) || value.some(x => typeof x !== "string")) {
throw new Error("values of ACL object need to be string arrays");
}

// "Useless" map to get rid of other properties inside array from toml parsing
out.set(key, value.map(v => v));
}

return out;
}

throw new Error("needs to be 'true', 'false', an object or an XML string");
},
dcc: types.string,
titleField: metaDataField,
Expand Down Expand Up @@ -632,10 +661,12 @@ const SCHEMA = {
// ==============================================================================================

// Customize array merge behavior
const merge = (a: Settings, b: Settings): Settings => deepmerge(a, b, { arrayMerge });
const mergeAll = (array: Settings[]) => deepmerge.all(array, { arrayMerge });
const arrayMerge: deepmerge.Options["arrayMerge"]
= (_destinationArray, sourceArray, _options) => sourceArray;
const mergeOptions: deepmerge.Options = {
arrayMerge: (_destinationArray, sourceArray, _options) => sourceArray,
isMergeableObject: isPlainObject,
};
const merge = (a: Settings, b: Settings): Settings => deepmerge(a, b, mergeOptions);
const mergeAll = (array: Settings[]) => deepmerge.all(array, mergeOptions);


// ==============================================================================================
Expand Down
5 changes: 5 additions & 0 deletions webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ const config: CallableOption = (_env, argv) => ({
patterns: [
{ from: path.join(__dirname, "assets/logo-wide.svg"), to: OUT_PATH },
{ from: path.join(__dirname, "assets/logo-narrow.svg"), to: OUT_PATH },
...argv.mode === "development" ? [{
from: path.join(__dirname, "assets/settings.toml"),
to: OUT_PATH,
noErrorOnMissing: true,
}] : [],

// Copy the font related files to output directory
{
Expand Down
Loading