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

fix: prevent re-sync after reloading the page if user related permission is set #2665

Merged
merged 5 commits into from
Nov 25, 2024
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
49 changes: 30 additions & 19 deletions src/app/core/permissions/ability/ability.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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";
import { merge } from "rxjs";
import { filter, firstValueFrom, merge } from "rxjs";
import { map } from "rxjs/operators";

/**
Expand Down Expand Up @@ -52,26 +52,23 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {

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

if (userRules.length === 0) {
// No rules or only default rules defined
const user = this.sessionInfo.value;
Logging.warn(
`no rules found for user "${user?.name}" with roles "${user?.roles}"`,
);
}
const userRules: DatabaseRule[] =
await this.interpolateUserVariables(rawUserRules);

this.ability.update(userRules);
return this.permissionEnforcer.enforcePermissionsOnLocalData(userRules);
}

private async getRulesForUser(rules: DatabaseRules): Promise<DatabaseRule[]> {
private getRulesForUser(rules: DatabaseRules): DatabaseRule[] {
const sessionInfo = this.sessionInfo.value;
if (!sessionInfo) {
return rules.public ?? [];
}

const rawUserRules: DatabaseRule[] = [];
if (rules.default) {
rawUserRules.push(...rules.default);
Expand All @@ -80,39 +77,53 @@ export class AbilityService extends LatestEntityLoader<Config<DatabaseRules>> {
const rulesForRole = rules[role] || [];
rawUserRules.push(...rulesForRole);
});
return this.interpolateUser(rawUserRules, sessionInfo);

if (rawUserRules.length === 0 && sessionInfo) {
// No rules or only default rules defined
Logging.warn(
`no rules found for user "${sessionInfo.name}" with roles "${sessionInfo.roles}"`,
);
}

return rawUserRules;
}

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

const user = await firstValueFrom(
// only emit once user entity is loaded (or "null" for user account without entity)
this.currentUser.pipe(filter((x) => x !== undefined)),
);
if (user && user["projects"]) {
sessionInfo.projects = user["projects"];
} else {
sessionInfo.projects = [];
}

const dynamicPlaceholders = {
user: sessionInfo,
};
return JSON.parse(JSON.stringify(rules), (_that, rawValue) => {
if (rawValue[0] !== "$") {
return rawValue;
}

let name = rawValue.slice(2, -1);
let name = rawValue.slice(2, -1); // extract name from "${name}"
if (name === "user.name") {
// the user account related entity (assured with prefix) is now stored in user.entityId
// mapping the previously valid ${user.name} here for backwards compatibility
name = "user.entityId";
}
const value = get({ user: sessionInfo }, name);
const value = get(dynamicPlaceholders, name);

if (typeof value === "undefined") {
throw new ReferenceError(`Variable ${name} is not defined`);
}

return value;
});
}) as DatabaseRule[];
}
}
8 changes: 7 additions & 1 deletion src/app/core/session/current-user-subject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { Injectable } from "@angular/core";

/**
* The Entity linked to the currently logged-in user, which can be used to pre-fill forms or customize workflows.
* This might be undefined even when logged in. E.g. when using an administrative support account.
*
* This value is
* - an entity object from the database, if the user account is linked to a valid entity (via the "exact_username" attribute)
* - `undefined even not logged in (e.g. when using a public form as an anonymous visitor)
* - `null` when no entity is linked to the current user or the linked entityId is invalid (doc not found in database)
*
* This distinction between "undefined" and "null" helps navigate some special cases, e.g. in the AbilityService.
*/
@Injectable()
export class CurrentUserSubject extends BehaviorSubject<Entity> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,3 @@
/*
* This file is part of ndb-core.
*
* ndb-core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ndb-core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ndb-core. If not, see <http://www.gnu.org/licenses/>.
*/

import { SessionManagerService } from "./session-manager.service";
import { LoginState } from "../session-states/login-state.enum";
import {
Expand Down Expand Up @@ -152,7 +135,6 @@ describe("SessionManagerService", () => {
entityId: adminUser.getId(),
});
await service.remoteLogin();
expect(currentUser.value).toBeUndefined();

// user entity available -> user should be set
await entityMapper.save(adminUser);
Expand All @@ -171,7 +153,7 @@ describe("SessionManagerService", () => {

expect(loadSpy).not.toHaveBeenCalled();
expect(loginStateSubject.value).toBe(LoginState.LOGGED_IN);
expect(TestBed.inject(CurrentUserSubject).value).toBeUndefined();
expect(TestBed.inject(CurrentUserSubject).value).toBeNull();
});

it("should allow other entities to log in", async () => {
Expand Down
17 changes: 10 additions & 7 deletions src/app/core/session/session-service/session-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,22 @@ export class SessionManagerService {
await this.initializeDatabaseForCurrentUser(session);
this.sessionInfo.next(session);
this.loginStateSubject.next(LoginState.LOGGED_IN);
if (session.entityId) {
this.configService.configUpdates.pipe(take(1)).subscribe(() =>
// requires initial config to be loaded first!
this.initUserEntity(session.entityId),
);
}
this.configService.configUpdates.pipe(take(1)).subscribe(() =>
// requires initial config to be loaded first!
this.initUserEntity(session.entityId),
);
}

private initUserEntity(entityId: string) {
if (!entityId) {
this.currentUser.next(null);
return;
}

const entityType = Entity.extractTypeFromId(entityId);
this.entityMapper
.load(entityType, entityId)
.catch(() => undefined)
.catch(() => null) // see CurrentUserSubject: emits "null" for non-existing user entity
.then((res) => this.currentUser.next(res));
this.updateSubscription = this.entityMapper
.receiveUpdates(entityType)
Expand Down
Loading