Skip to content
Merged
84 changes: 84 additions & 0 deletions src/Turnierplan.App.Test.Unit/Security/AccessValidatorTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Turnierplan.App.Security;
using Turnierplan.Core.ApiKey;
using Turnierplan.Core.Folder;
using Turnierplan.Core.Image;
using Turnierplan.Core.Organization;
using Turnierplan.Core.RoleAssignment;
using Turnierplan.Core.SeedWork;
using Turnierplan.Core.Tournament;
using Turnierplan.Core.Venue;

namespace Turnierplan.App.Test.Unit.Security;

public sealed class AccessValidatorTest
{
[Fact]
public void IsActionAllowed___When_Called_With_Basic_Target___Returns_Expected_Result()
{
var target = new Organization("Test");

var principal = new Principal(PrincipalKind.User, "faa6d5d3-93ad-410e-bc81-171a04cf0130");
var otherPrincipal = new Principal(PrincipalKind.User, "98f8cb8c-606f-47fc-805f-244210e1df51");

target.AddRoleAssignment(Role.Reader, principal);
target.AddRoleAssignment(Role.Contributor, otherPrincipal);

AccessValidator.IsActionAllowed(target, Actions.GenericRead, principal).Should().BeTrue();
AccessValidator.IsActionAllowed(target, Actions.GenericWrite, principal).Should().BeFalse();

AccessValidator.IsActionAllowed(target, Actions.GenericRead, otherPrincipal).Should().BeTrue();
AccessValidator.IsActionAllowed(target, Actions.GenericWrite, otherPrincipal).Should().BeTrue();
}

[Fact]
public void IsActionAllowed___When_Called_With_Indirect_Target___Returns_Expected_Result()
{
var organization = new Organization("Test");

var principal = new Principal(PrincipalKind.User, "faa6d5d3-93ad-410e-bc81-171a04cf0130");
var otherPrincipal = new Principal(PrincipalKind.User, "98f8cb8c-606f-47fc-805f-244210e1df51");

organization.AddRoleAssignment(Role.Reader, principal);
organization.AddRoleAssignment(Role.Contributor, otherPrincipal);

void Test<T>(Func<T> factory)
where T : Entity<long>, IEntityWithRoleAssignments<T>
{
var target = factory();

AccessValidator.IsActionAllowed(target, Actions.GenericRead, principal).Should().BeTrue();
AccessValidator.IsActionAllowed(target, Actions.GenericWrite, principal).Should().BeFalse();

AccessValidator.IsActionAllowed(target, Actions.GenericRead, otherPrincipal).Should().BeTrue();
AccessValidator.IsActionAllowed(target, Actions.GenericWrite, otherPrincipal).Should().BeTrue();
}

Test(() => new ApiKey(organization, "Test", null, DateTime.MaxValue));
Test(() => new Image(organization, "Test", ImageType.SquareLargeLogo, "", 0, 1, 1));
Test(() => new Folder(organization, "Test"));
Test(() => new Tournament(organization, "Test", Visibility.Public));
Test(() => new Venue(organization, "Test", ""));
}

[Fact]
public void IsActionAllowed___When_Called_With_Tournament_Target_And_Role_Assignment_On_Folder___Returns_Expected_Result()
{
var organization = new Organization("Test");
var folder = new Folder(organization, "Test");

var principal = new Principal(PrincipalKind.User, "faa6d5d3-93ad-410e-bc81-171a04cf0130");
var otherPrincipal = new Principal(PrincipalKind.User, "98f8cb8c-606f-47fc-805f-244210e1df51");

folder.AddRoleAssignment(Role.Reader, principal);
folder.AddRoleAssignment(Role.Contributor, otherPrincipal);

var target = new Tournament(organization, "Test", Visibility.Public);
target.SetFolder(folder);

AccessValidator.IsActionAllowed(target, Actions.GenericRead, principal).Should().BeTrue();
AccessValidator.IsActionAllowed(target, Actions.GenericWrite, principal).Should().BeFalse();

AccessValidator.IsActionAllowed(target, Actions.GenericRead, otherPrincipal).Should().BeTrue();
AccessValidator.IsActionAllowed(target, Actions.GenericWrite, otherPrincipal).Should().BeTrue();
}
}
26 changes: 26 additions & 0 deletions src/Turnierplan.App.Test.Unit/Security/ActionsTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Turnierplan.App.Security;
using Turnierplan.Core.RoleAssignment;

namespace Turnierplan.App.Test.Unit.Security;

public sealed class ActionsTest
{
[Fact]
public void IsAllowed___When_Called_With_Various_Roles___Returns_Correct_Value()
{
Actions.ReadOrWriteRoleAssignments.IsAllowed([Role.Owner]).Should().BeTrue();
Actions.ReadOrWriteRoleAssignments.IsAllowed([Role.Contributor]).Should().BeFalse();
Actions.ReadOrWriteRoleAssignments.IsAllowed([Role.Reader]).Should().BeFalse();

Actions.GenericWrite.IsAllowed([Role.Owner]).Should().BeTrue();
Actions.GenericWrite.IsAllowed([Role.Contributor]).Should().BeTrue();
Actions.GenericWrite.IsAllowed([Role.Reader]).Should().BeFalse();

Actions.GenericRead.IsAllowed([Role.Owner]).Should().BeTrue();
Actions.GenericRead.IsAllowed([Role.Contributor]).Should().BeTrue();
Actions.GenericRead.IsAllowed([Role.Reader]).Should().BeTrue();

Actions.GenericWrite.IsAllowed([Role.Reader, Role.Contributor]).Should().BeTrue();
Actions.GenericWrite.IsAllowed([Role.Reader]).Should().BeFalse();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface TurnierplanAccessToken {
exp: number;
mail: string;
name: string;
rol: string | string[];
adm?: string;
uid: string;
}

Expand All @@ -24,7 +24,7 @@ export class AuthenticationService implements OnDestroy {
private static readonly localStorageUserIdKey = 'tp_id_userId';
private static readonly localStorageUserNameKey = 'tp_id_userName';
private static readonly localStorageUserEMailKey = 'tp_id_userEMail';
private static readonly localStorageUserRolesKey = 'tp_id_userRoles';
private static readonly localStorageUserAdministratorKey = 'tp_id_userAdmin';
private static readonly localStorageAccessTokenExpiryKey = 'tp_id_accTokenExp';
private static readonly localStorageRefreshTokenExpiryKey = 'tp_id_rfsTokenExp';
private static readonly refreshAccessTokenIfExpiresInLessThanSeconds = 300;
Expand Down Expand Up @@ -66,7 +66,7 @@ export class AuthenticationService implements OnDestroy {
decodedAccessToken.uid,
decodedAccessToken.name,
decodedAccessToken.mail,
decodedAccessToken.rol,
decodedAccessToken.adm === 'true',
decodedAccessToken.exp,
decodedRefreshToken.exp
);
Expand Down Expand Up @@ -119,17 +119,8 @@ export class AuthenticationService implements OnDestroy {
return expiry !== undefined && expiry * 1000 > new Date().getTime();
}

public checkIfUserHasRole(roleId: string): Observable<boolean> {
return this.authentication$.pipe(
map(() => {
const storedValue = localStorage.getItem(AuthenticationService.localStorageUserRolesKey);
if (storedValue === null || storedValue === '') {
return false;
}

return storedValue.split(';').some((x) => x === roleId);
})
);
public checkIfUserIsAdministrator(): Observable<boolean> {
return this.authentication$.pipe(map(() => localStorage.getItem(AuthenticationService.localStorageUserAdministratorKey) === 'true'));
}

public changePassword(
Expand Down Expand Up @@ -214,7 +205,7 @@ export class AuthenticationService implements OnDestroy {
decodedAccessToken.uid,
decodedAccessToken.name,
decodedAccessToken.mail,
decodedAccessToken.rol,
decodedAccessToken.adm === 'true',
decodedAccessToken.exp,
decodedRefreshToken.exp
);
Expand Down Expand Up @@ -295,21 +286,14 @@ export class AuthenticationService implements OnDestroy {
userId: string,
userName: string,
userEMail: string,
userRoles: string | string[],
userIsAdmin: boolean,
accessTokenExpiry: number,
refreshTokenExpiry: number
): void {
localStorage.setItem(AuthenticationService.localStorageUserIdKey, userId);
localStorage.setItem(AuthenticationService.localStorageUserNameKey, userName);
localStorage.setItem(AuthenticationService.localStorageUserEMailKey, userEMail);

if (userRoles === undefined || userRoles === '' || (userRoles as string[])?.length === 0) {
localStorage.removeItem(AuthenticationService.localStorageUserRolesKey);
} else if (typeof userRoles === 'string') {
localStorage.setItem(AuthenticationService.localStorageUserRolesKey, userRoles);
} else {
localStorage.setItem(AuthenticationService.localStorageUserRolesKey, userRoles.join(';'));
}
localStorage.setItem(AuthenticationService.localStorageUserAdministratorKey, `${userIsAdmin}`);

localStorage.setItem(AuthenticationService.localStorageAccessTokenExpiryKey, `${accessTokenExpiry}`);
localStorage.setItem(AuthenticationService.localStorageRefreshTokenExpiryKey, `${refreshTokenExpiry}`);
Expand Down
7 changes: 3 additions & 4 deletions src/Turnierplan.App/Client/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const de = {
OrganizationCount: 'Organisationen'
},
AdministratorWarning:
'Sie sind mit einem Konto mit Administrator-Rolle angemeldet. Aktuell sehen Sie aber dennoch nur Organisationen, welche diesem Konto zugeordnet sind - jedoch nicht die Organisationen der anderen Benutzer.',
'Sie sind mit einem Administrator-Konto angemeldet und haben unbeschränkten Zugriff auf alle Organisationen - auch die von anderen Benutzern.',
NoOrganizations:
'Sie sind keinen Organisationen zugehörig.\nErstellen Sie eine neue Organisation, um Turniere anzulegen und zu bearbeiten',
NewOrganization: 'Neue Organisation',
Expand All @@ -118,8 +118,7 @@ export const de = {
EMail: 'E-Mail',
CreatedAt: 'Erstellt am',
LastPasswordChange: 'Letzte Passwortänderung',
Roles: 'Rollen',
NoRoles: 'keine'
Administrator: 'Admin'
},
DeleteUser: {
Title: 'Benutzer löschen',
Expand Down Expand Up @@ -869,7 +868,7 @@ export const de = {
Validity180: '180 Tage'
},
OrganizationNotice: 'Es wird ein neuer API-Schlüssel in der Organisation <span class="fw-bold">{{organizationName}}</span> angelegt.',
AccessNotice: 'Mit diesem Schlüssel kann auf alle Turniere der Organisation zugegriffen werden',
AccessNotice: 'Ein neuer API-Schlüssel erhält standardmäßig Leserechte für die aktuelle Organisation.',
Submit: 'Erstellen',
SuccessInformation: {
Title: 'API-Schlüssel wurde erstellt',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { Directive, ElementRef, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { ReplaySubject, Subject, switchMap, takeUntil } from 'rxjs';
import { Directive, ElementRef, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';

import { AuthenticationService } from '../../../core/services/authentication.service';

@Directive({
standalone: false,
selector: '[tpHasRole]'
selector: '[tpIsAdministrator]'
})
export class HasRoleDirective implements OnInit, OnDestroy {
private readonly roleId$ = new ReplaySubject<string>(1);
export class IsAdministratorDirective implements OnInit, OnDestroy {
private readonly destroyed$ = new Subject<void>();

constructor(
Expand All @@ -17,29 +16,21 @@ export class HasRoleDirective implements OnInit, OnDestroy {
private readonly authenticationService: AuthenticationService
) {}

@Input()
public set tpHasRole(roleId: string) {
this.roleId$.next(roleId);
}

public ngOnInit(): void {
this.roleId$
.pipe(
switchMap((roleId) => this.authenticationService.checkIfUserHasRole(roleId)),
takeUntil(this.destroyed$)
)
this.authenticationService
.checkIfUserIsAdministrator()
.pipe(takeUntil(this.destroyed$))
.subscribe({
next: (hasRole) => {
next: (isAdministrator) => {
this.viewContainer.clear();
if (hasRole) {
if (isAdministrator) {
this.viewContainer.createEmbeddedView(this.templateRef);
}
}
});
}

public ngOnDestroy(): void {
this.roleId$.complete();
this.destroyed$.next();
this.destroyed$.complete();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { map } from 'rxjs';

import { AuthenticationService } from '../../core/services/authentication.service';

export const hasRoleGuard = (roleId: string): CanActivateFn => {
export const isAdministratorGuard = (): CanActivateFn => {
return () => {
const router = inject(Router);
const authenticationService = inject(AuthenticationService);

return authenticationService.checkIfUserHasRole(roleId).pipe(map((hasRole) => (hasRole ? true : router.createUrlTree(['/portal']))));
return authenticationService
.checkIfUserIsAdministrator()
.pipe(map((isAdministrator) => (isAdministrator ? true : router.createUrlTree(['/portal']))));
};
};
3 changes: 0 additions & 3 deletions src/Turnierplan.App/Client/src/app/portal/helpers/role-ids.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<th translate="Portal.Administration.Users.EMail"></th>
<th translate="Portal.Administration.Users.CreatedAt"></th>
<th translate="Portal.Administration.Users.LastPasswordChange"></th>
<th translate="Portal.Administration.Users.Roles"></th>
<th translate="Portal.Administration.Users.Administrator"></th>
<th></th>
</tr>
</thead>
Expand All @@ -34,12 +34,8 @@
<td class="align-middle small">{{ user.createdAt | translateDate: 'medium' }}</td>
<td class="align-middle small">{{ user.lastPasswordChange | translateDate: 'medium' }}</td>
<td class="align-middle">
@if (user.roles.length === 0) {
<span class="ms-1 small text-secondary" translate="Portal.Administration.Users.NoRoles"></span>
} @else {
@for (role of user.roles; track role) {
<i class="ms-1 bi" [ngClass]="getRoleIcon(role.id)" [ngbTooltip]="role.name"></i>
}
@if (user.isAdministrator) {
<i class="bi bi-check-circle"></i>
}
</td>
<td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { AuthenticationService } from '../../../core/services/authentication.ser
import { NotificationService } from '../../../core/services/notification.service';
import { PageFrameNavigationTab } from '../../components/page-frame/page-frame.component';
import { LoadingState } from '../../directives/loading-state/loading-state.directive';
import { RoleIds } from '../../helpers/role-ids';
import { TitleService } from '../../services/title.service';

@Component({
Expand Down Expand Up @@ -55,15 +54,6 @@ export class AdministrationPageComponent implements OnInit {
});
}

protected getRoleIcon(roleId: string): string {
switch (roleId) {
case RoleIds.administratorRoleId:
return 'bi-gear';
default:
throw new Error(`Unknown role ID: ${roleId}`);
}
}

protected deleteButtonClicked(id: string, template: TemplateRef<unknown>): void {
if (id === this.currentUserId) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<tp-page-frame [title]="'Portal.LandingPage.Title' | translate" [navigationTabs]="pages">
<ng-template #buttons>
<tp-action-button
*tpHasRole="administratorRoleId"
*tpIsAdministrator
[title]="'Portal.Administration.Title'"
[type]="'outline-primary'"
[icon]="'gear'"
Expand All @@ -18,7 +18,7 @@
@if (organizations.length === 0) {
<div class="card-body">
<tp-alert
*tpHasRole="administratorRoleId"
*tpIsAdministrator
[type]="'warning'"
[icon]="'exclamation-triangle'"
[text]="'Portal.LandingPage.AdministratorWarning'" />
Expand All @@ -31,7 +31,7 @@
<div class="card-header d-flex flex-row align-items-center">
<tp-badge context="LandingPage" label="OrganizationCount" [value]="organizations.length" />
</div>
<div class="card-body pb-0" *tpHasRole="administratorRoleId">
<div class="card-body pb-0" *tpIsAdministrator>
<tp-alert [type]="'warning'" [icon]="'exclamation-triangle'" [text]="'Portal.LandingPage.AdministratorWarning'" />
</div>
<div class="card-body d-flex flex-row flex-wrap gap-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@ import { take } from 'rxjs';
import { OrganizationDto, OrganizationsService } from '../../../api';
import { PageFrameNavigationTab } from '../../components/page-frame/page-frame.component';
import { LoadingState } from '../../directives/loading-state/loading-state.directive';
import { RoleIds } from '../../helpers/role-ids';
import { TitleService } from '../../services/title.service';

@Component({
standalone: false,
templateUrl: './landing-page.component.html'
})
export class LandingPageComponent implements OnInit {
protected readonly administratorRoleId = RoleIds.administratorRoleId;

protected loadingState: LoadingState = { isLoading: true };
protected organizations: OrganizationDto[] = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<div
class="tp-text-pre-wrap mb-3"
[innerHTML]="'Portal.UserInfoPopover.Text' | translate: { userName: currentUser.displayName }"></div>
<ng-container *tpHasRole="administratorRoleId">
<ng-container *tpIsAdministrator>
<div class="d-flex flex-row tp-cursor-pointer mt-1" [routerLink]="'/portal/administration'">
<i class="bi bi-gear me-2" aria-hidden="true"></i>
<span class="text-decoration-underline" [translate]="'Portal.UserInfoPopover.Administration'"></span>
Expand Down
Loading