Skip to content

Commit

Permalink
feat: support for user linked project permissions (#2390)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomwwinter authored Jun 17, 2024
1 parent 38fad0c commit 376df02
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 8 deletions.
24 changes: 24 additions & 0 deletions doc/compodoc_sources/concepts/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ The `admin_app` role simpy allows user with this role to do everything, without
To learn more about how to define rules, have a look at
the [CASL documentation](https://casl.js.org/v5/en/guide/define-rules#rules).

It is also possible to access information of the user sending the request. E.g.:

```json
{
"subject": "org.couchdb.user",
"action": "update",
"fields": [
"password"
],
"conditions": {
"name": "${user.name}",
"projects": {
"$in": "${user.projects}"
}
}
}
```

This allows users to update the `password` property of their *own* document in the `_users` database.
Placeholders can currently access properties that the _replication-backend_ explicitly adds to the auth user object.
Other available values are `${user.roles}` (array of roles of the user) and `${user.projects}` (the "projects" attribute of the user's entity that is linked to the account through the "exact_username" in Keycloak).

For more information on how to write rules have a look at the [CASL documentation](https://casl.js.org/v5/en/guide/intro).

### Implementing components with permissions

This section is about code using permissions to read and edit **entities**.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,102 @@ describe("HandleDefaultValuesUseCase", () => {
expect(formGroup.get("field-2").value).toBe("bar");
}));

it("should set default array value on FormControl, if target field empty", fakeAsync(() => {
// given
let formGroup = getDefaultInheritedFormGroup();

let fieldConfigs: [string, EntitySchemaField][] = [
[
"field-2",
{
isArray: true,
defaultValue: {
mode: "inherited",
field: "foo",
localAttribute: "reference-1",
},
},
],
];

let entity0 = new Entity();
entity0["foo"] = ["bar", "doo"];
mockEntityMapperService.load.and.returnValue(Promise.resolve(entity0));

// when
service.handleFormGroup(formGroup, fieldConfigs, true);
formGroup.get("reference-1").setValue("Entity:0");
tick(10); // fetching reference is always async

// then
expect(formGroup.get("field-1").value).toBe(null);
expect(formGroup.get("field-2").value).toEqual(["bar", "doo"]);
}));

it("should set value on FormControl, if source is single value array", fakeAsync(() => {
// given
let formGroup = getDefaultInheritedFormGroup();

let fieldConfigs: [string, EntitySchemaField][] = [
[
"field-2",
{
isArray: true,
defaultValue: {
mode: "inherited",
field: "foo",
localAttribute: "reference-1",
},
},
],
];

let entity0 = new Entity();
entity0["foo"] = ["bar"];
mockEntityMapperService.load.and.returnValue(Promise.resolve(entity0));

// when
service.handleFormGroup(formGroup, fieldConfigs, true);
formGroup.get("reference-1").setValue(["Entity:0"]);
tick(10); // fetching reference is always async

// then
expect(formGroup.get("field-1").value).toBe(null);
expect(formGroup.get("field-2").value).toEqual(["bar"]);
}));

it("should not set value on FormControl, if source is multi value array", fakeAsync(() => {
// given
let formGroup = getDefaultInheritedFormGroup();

let fieldConfigs: [string, EntitySchemaField][] = [
[
"field-2",
{
isArray: true,
defaultValue: {
mode: "inherited",
field: "foo",
localAttribute: "reference-1",
},
},
],
];

let entity0 = new Entity();
entity0["foo"] = ["bar", "doo"];
mockEntityMapperService.load.and.returnValue(Promise.resolve(entity0));

// when
service.handleFormGroup(formGroup, fieldConfigs, true);
formGroup.get("reference-1").setValue(["Entity:0", "Entity:1"]);
tick(10); // fetching reference is always async

// then
expect(formGroup.get("field-1").value).toBe(null);
expect(formGroup.get("field-2").value).toEqual(null);
}));

it("should not set default value on FormControl, if target field is dirty and not empty", fakeAsync(() => {
// given
let formGroup = getDefaultInheritedFormGroup();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,15 @@ export class HandleDefaultValuesUseCase {
return;
}

// source field is array, use first element if only one element
if (Array.isArray(change)) {
if (change.length === 1) {
change = change[0];
} else {
return;
}
}

let parentEntity: Entity = await this.entityMapper.load(
Entity.extractTypeFromId(change),
change,
Expand All @@ -103,7 +112,7 @@ export class HandleDefaultValuesUseCase {
}

if (isArray) {
targetFormControl.setValue([parentEntity[defaultValueConfig.field]]);
targetFormControl.setValue([...parentEntity[defaultValueConfig.field]]);
} else {
targetFormControl.setValue(parentEntity[defaultValueConfig.field]);
}
Expand Down Expand Up @@ -193,7 +202,7 @@ export class HandleDefaultValuesUseCase {
return false;
}

if (isArray && formControl.value && formControl.value.size > 0) {
if (isArray && formControl.value && formControl.value.length > 0) {
return false;
}

Expand Down
20 changes: 15 additions & 5 deletions src/app/core/permissions/ability/ability.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { LoggingService } from "../../logging/logging.service";
import { get } from "lodash-es";
import { LatestEntityLoader } from "../../entity/latest-entity-loader";
import { SessionInfo, SessionSubject } from "../../session/auth/session-info";
import { CurrentUserSubject } from "../../session/current-user-subject";

/**
* This service sets up the `EntityAbility` injectable with the JSON defined rules for the currently logged in user.
Expand All @@ -20,6 +21,7 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {
constructor(
private ability: EntityAbility,
private sessionInfo: SessionSubject,
private currentUser: CurrentUserSubject,
private permissionEnforcer: PermissionEnforcerService,
entityMapper: EntityMapperService,
logger: LoggingService,
Expand All @@ -41,10 +43,10 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {
);
}

private updateAbilityWithUserRules(rules: DatabaseRules): Promise<any> {
private async updateAbilityWithUserRules(rules: DatabaseRules): Promise<any> {
// If rules object is empty, everything is allowed
const userRules: DatabaseRule[] = rules
? this.getRulesForUser(rules)
? await this.getRulesForUser(rules)
: [{ action: "manage", subject: "all" }];

if (userRules.length === 0) {
Expand All @@ -58,7 +60,7 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {
return this.permissionEnforcer.enforcePermissionsOnLocalData(userRules);
}

private getRulesForUser(rules: DatabaseRules): DatabaseRule[] {
private async getRulesForUser(rules: DatabaseRules): Promise<DatabaseRule[]> {
const sessionInfo = this.sessionInfo.value;
if (!sessionInfo) {
return rules.public ?? [];
Expand All @@ -76,8 +78,16 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {

private interpolateUser(
rules: DatabaseRule[],
user: SessionInfo,
sessionInfo: SessionInfo,
): DatabaseRule[] {
const user = this.currentUser.value;

if (user && user["projects"]) {
sessionInfo.projects = user["projects"];
} else {
sessionInfo.projects = [];
}

return JSON.parse(JSON.stringify(rules), (_that, rawValue) => {
if (rawValue[0] !== "$") {
return rawValue;
Expand All @@ -89,7 +99,7 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {
// mapping the previously valid ${user.name} here for backwards compatibility
name = "user.entityId";
}
const value = get({ user }, name);
const value = get({ user: sessionInfo }, name);

if (typeof value === "undefined") {
throw new ReferenceError(`Variable ${name} is not defined`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export class PermissionEnforcerService {
private configService: ConfigService,
) {}

async enforcePermissionsOnLocalData(userRules: DatabaseRule[]) {
async enforcePermissionsOnLocalData(
userRules: DatabaseRule[],
): Promise<void> {
const userRulesString = JSON.stringify(userRules);
if (!this.sessionInfo.value || !this.userRulesChanged(userRulesString)) {
return;
Expand Down
5 changes: 5 additions & 0 deletions src/app/core/session/auth/session-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export interface SessionInfo {
*/
roles: string[];

/**
* List of linked projects
*/
projects?: string[];

/**
* ID of the entity which is connected with the user account.
*
Expand Down

0 comments on commit 376df02

Please sign in to comment.