Skip to content

Commit

Permalink
feat!: impersonate users (#3042)
Browse files Browse the repository at this point in the history
* feat: added contextId to user data classes

* feat: contextId in H5PPlayer.play

* feat: contextId in h5p-express and docs

* test: added integration tests for contextId

* test: contextId in rest example

* feat(mongo-s3): added contextId to user data storage

* refactor: permission system

* refactor: permissions system

* test: fix tests

* test: fix more tests

* test: fix more tests

* refactor!: moved permissions from IUser to IPermissionSystem

* refactor: permission system

* refactor: fine grained permission types and REST example

* refactor: prettier

* refactor: more fine grained user data permission handling

* feat: impersonate users and read only states

* test: fixed test for new content user data interface

* refactor: style improvement and docs

* feat: add locales

* test: fix tests

* docs: extended docs

* test: added test for impersonation

* test: added impersonation and read only state to REST example
  • Loading branch information
sr258 authored Jul 15, 2023
1 parent 3bb30a3 commit 75d7b68
Show file tree
Hide file tree
Showing 134 changed files with 2,441 additions and 1,023 deletions.
4 changes: 3 additions & 1 deletion docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
* [Constructing H5PEditor](usage/h5p-editor-constructor.md)
* [REST Example](examples/rest/README.md)
- Advanced usage
* [Authorization](advanced/authorization.md)
* [User content state](advanced/user-content-state.md)
* [Multiple user states per object](advanced/context-ids.md)
* [Impersonating users](advanced/impersonation.md)
* [Basic completion tracking](advanced/completion-tracking.md)
* [Localization](advanced/localization.md)
* [Cluster](advanced/cluster.md)
Expand All @@ -16,7 +19,6 @@
* [Performance optimizations](advanced/performance-optimizations.md)
* [Privacy](advanced/privacy.md)
* [Forward proxy support](advanced/proxy.md)
* [Multiple content states per object](advanced/context-ids.md)
- NPM packages
- h5p-mongos3
* [Mongo/S3 Content Storage](packages/h5p-mongos3/mongo-s3-content-storage.md)
Expand Down
17 changes: 17 additions & 0 deletions docs/advanced/authorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Authorization

Many actions users perform in the H5P system need authorization. By default the
library will allow everything to every user. You can customize who can do what,
but passing in an implementation of `IPermissionSystem` into
`options.permissionSystem` of the `H5PPlayer` or `H5PEditor` constructor. The
library then calls the methods of `IPermissionSystem` whenever a user performs an
action that requires authorization.

See the documentation of `IPermissionSystem` for and the
[`ExamplePermissionSystem`](/packages/h5p-rest-example-server/src/ExamplePermissionSystem.ts)
for reference how to implement the permission system.

Note that the `IPermissionSystem` is a generic. You can use any sub-type of
`IUser` as the generic type. The call of the methods of `IPermissionSystem` will
include a user of the generic type. This is the user object you've injected in
your controllers. That means you can add any arbitrary date to it, like roles.
30 changes: 30 additions & 0 deletions docs/advanced/impersonation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Impersonating users

It is possible to impersonate users when viewing a H5P object. This means that
you can display another user's user state instead of your own. This is useful,
if you want to implement a feature in which teachers can review the work of
students.

You do this by setting `options.asUserId` of the `H5PPlayer.render` method. Make
sure that you [authorize users](authorization.md) as required in the permission
system.

## Read-only states

In most cases in which your users impersonate another user, you'll want to
disable saving the user state for the impersonator. You can do this by setting
`options.readOnlyState` to true when calling `H5PPlayer.render`. This will do
the following:

- set the save interval to the longest possible value
- adds the query parameter `ignorePost=yes` to the Ajax route responsible for
handling user states

The query parameter is necessary, as the H5P core client doesn't support user
states that are read only. We work around this by ignoring a post calls when the
query parameter is set in h5p-express. If the query parameter is set, we simply
return a success, so the H5P core client doesn't realize we didn't save the
state. If you don't use this package, you must do this yourself.

Obviously you also have to make sure malicious users won't change the user state
of others, by rejecting these operations in the authorization/permission system!
10 changes: 10 additions & 0 deletions docs/usage/h5p-editor-constructor.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ for an implementation sample using the urlGenerator.
Allows you to customize styles and scripts of the client. Also allows passing in
a lock implementation (needed for multi-process or clustered setups).

## options.permissionSystem (optional)

By passing in an implementation of `IPermissionSystem` you get fine-grained
control over who can do what in your system. The library calls the methods of
`IPermissionSystem` whenever a user performs an action that requires
authorization.

If you leave options.permissionSystem `undefined`, the library will allow
everything to everyone!

## contentUserDataStorage (optional)

The `contentUserDataStorage` handles saving and loading user states, so users
Expand Down
6 changes: 0 additions & 6 deletions packages/h5p-examples/src/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@ export default class User implements IUser {
constructor() {
this.id = '1';
this.name = 'Firstname Surname';
this.canInstallRecommended = true;
this.canUpdateAndInstallLibraries = true;
this.canCreateRestricted = true;
this.type = 'local';
this.email = '[email protected]';
}

public canCreateRestricted: boolean;
public canInstallRecommended: boolean;
public canUpdateAndInstallLibraries: boolean;
public email: string;
public id: string;
public name: string;
Expand Down
19 changes: 19 additions & 0 deletions packages/h5p-examples/src/expressRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ export default function (
contextId:
typeof req.query.contextId === 'string'
? req.query.contextId
: undefined,
// You can impersonate other users to view their content
// state by setting the query parameter asUserId.
// Example:
// `/h5p/play/XXXX?asUserId=YYY`
asUserId:
typeof req.query.asUserId === 'string'
? req.query.asUserId
: undefined,
// You can disabling saving of the user state, but still
// display it by setting the query parameter
// `readOnlyState` to `yes`. This is useful if you want
// to review other users' states by setting `asUserId`
// and don't want to change their state.
// Example:
// `/h5p/play/XXXX?readOnlyState=yes`
readOnlyState:
typeof req.query.readOnlyState === 'string'
? req.query.readOnlyState === 'yes'
: undefined
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ export default class ContentUserDataController {
? req.query.contextId
: undefined;

const asUserId =
typeof req.query.asUserId === 'string'
? req.query.asUserId
: undefined;

const result = await this.contentUserDataManager.getContentUserData(
contentId,
dataType,
subContentId,
req.user,
contextId
contextId,
asUserId
);

if (!result || !result.userState) {
Expand All @@ -69,6 +75,32 @@ export default class ContentUserDataController {
typeof req.query.contextId === 'string'
? req.query.contextId
: undefined;
const asUserId =
typeof req.query.asUserId === 'string'
? req.query.asUserId
: undefined;
const ignorePost =
typeof req.query.ignorePost === 'string'
? req.query.ignorePost
: undefined;

// The ignorePost query parameter allows us to cancel requests that
// would fail later, when the ContentUserDataManager would deny write
// requests to user states. It is necessary, as the H5P JavaScript core
// client doesn't support displaying a state while saving is disabled.
// We implement this feature by setting a very long autosave frequency,
// rejecting write requests in the permission system and using the
// ignorePost query parameter.
if (ignorePost == 'yes') {
res.status(200).json(
new AjaxSuccessResponse(
undefined,
'The user state was not saved, as the query parameter ignorePost was set.'
)
);
return;
}

const { user, body } = req;

await this.contentUserDataManager.createOrUpdateContentUserData(
Expand All @@ -79,7 +111,8 @@ export default class ContentUserDataController {
body.invalidate === 1 || body.invalidate === '1',
body.preload === 1 || body.preload === '1',
user,
contextId
contextId,
asUserId
);

res.status(200).json(new AjaxSuccessResponse(undefined)).end();
Expand Down
14 changes: 12 additions & 2 deletions packages/h5p-express/test/ContentUserDataRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('ContentUserData endpoint adapter', () => {
false,
true,
user,
undefined,
undefined
);
expect(res.status).toBe(200);
Expand Down Expand Up @@ -129,7 +130,8 @@ describe('ContentUserData endpoint adapter', () => {
false,
true,
user,
'cid1'
'cid1',
undefined
);
expect(res.status).toBe(200);
});
Expand All @@ -150,6 +152,7 @@ describe('ContentUserData endpoint adapter', () => {
dataType,
subContentId,
user,
undefined,
undefined
);
expect(res.status).toBe(200);
Expand All @@ -170,7 +173,14 @@ describe('ContentUserData endpoint adapter', () => {

expect(
mockContentUserDataManager.getContentUserData
).toHaveBeenCalledWith(contentId, dataType, subContentId, user, 'cid1');
).toHaveBeenCalledWith(
contentId,
dataType,
subContentId,
user,
'cid1',
undefined
);
expect(res.status).toBe(200);
expect(res.body).toEqual({
data: mockReturnData.userState,
Expand Down
6 changes: 0 additions & 6 deletions packages/h5p-express/test/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@ export default class User implements IUser {
constructor() {
this.id = '1';
this.name = 'Firstname Surname';
this.canInstallRecommended = true;
this.canUpdateAndInstallLibraries = true;
this.canCreateRestricted = true;
this.type = 'local';
this.email = '[email protected]';
}

public canCreateRestricted: boolean;
public canInstallRecommended: boolean;
public canUpdateAndInstallLibraries: boolean;
public email: string;
public id: string;
public name: string;
Expand Down
15 changes: 11 additions & 4 deletions packages/h5p-html-exporter/test/HtmlExporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import LibraryManager from '../../h5p-server/src/LibraryManager';
import PackageImporter from '../../h5p-server/src/PackageImporter';
import HtmlExporter from '../src/HtmlExporter';
import { IIntegration } from '../../h5p-server/src/types';
import { LaissezFairePermissionSystem } from '../../h5p-server';

import User from './User';

Expand All @@ -31,17 +32,20 @@ async function importAndExportHtml(
await fsExtra.ensureDir(libraryDir);

const user = new User();
user.canUpdateAndInstallLibraries = true;

const contentStorage = new FileContentStorage(contentDir);
const contentManager = new ContentManager(contentStorage);
const contentManager = new ContentManager(
contentStorage,
new LaissezFairePermissionSystem()
);
const libraryStorage = new FileLibraryStorage(libraryDir);
const libraryManager = new LibraryManager(libraryStorage);
const config = new H5PConfig(null);

const packageImporter = new PackageImporter(
libraryManager,
config,
new LaissezFairePermissionSystem(),
contentManager,
new ContentStorer(contentManager, libraryManager, undefined)
);
Expand Down Expand Up @@ -222,17 +226,20 @@ describe('HtmlExporter template', () => {
await fsExtra.ensureDir(libraryDir);

const user = new User();
user.canUpdateAndInstallLibraries = true;

const contentStorage = new FileContentStorage(contentDir);
const contentManager = new ContentManager(contentStorage);
const contentManager = new ContentManager(
contentStorage,
new LaissezFairePermissionSystem()
);
const libraryStorage = new FileLibraryStorage(libraryDir);
const libraryManager = new LibraryManager(libraryStorage);
const config = new H5PConfig(null);

const packageImporter = new PackageImporter(
libraryManager,
config,
new LaissezFairePermissionSystem(),
contentManager,
new ContentStorer(contentManager, libraryManager, undefined)
);
Expand Down
6 changes: 0 additions & 6 deletions packages/h5p-html-exporter/test/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@ export default class User implements IUser {
constructor() {
this.id = '1';
this.name = 'Firstname Surname';
this.canInstallRecommended = true;
this.canUpdateAndInstallLibraries = true;
this.canCreateRestricted = true;
this.type = 'local';
this.email = '[email protected]';
}

public canCreateRestricted: boolean;
public canInstallRecommended: boolean;
public canUpdateAndInstallLibraries: boolean;
public email: string;
public id: string;
public name: string;
Expand Down
11 changes: 6 additions & 5 deletions packages/h5p-mongos3/src/MongoContentUserDataStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,18 +93,19 @@ export default class MongoContentUserDataStorage
contentId: ContentId,
dataType: string,
subContentId: string,
user: IUser,
userId: string,
contextId?: string
): Promise<IContentUserData> {
log.debug(
`getContentUserData: loading contentUserData for contentId ${contentId} and userId ${user.id} and contextId ${contextId}`
`getContentUserData: loading contentUserData for contentId ${contentId} and userId ${userId} and contextId ${contextId}`
);

return this.cleanMongoUserData(
await this.userDataCollection.findOne<IContentUserData>({
contentId,
dataType,
subContentId,
userId: user.id,
userId: userId,
contextId
})
);
Expand Down Expand Up @@ -193,14 +194,14 @@ export default class MongoContentUserDataStorage

public async getContentUserDataByContentIdAndUser(
contentId: ContentId,
user: IUser,
userId: string,
contextId?: string
): Promise<IContentUserData[]> {
return (
await this.userDataCollection
.find<IContentUserData>({
contentId,
userId: user.id,
userId,
contextId
})
.toArray()
Expand Down
Loading

0 comments on commit 75d7b68

Please sign in to comment.