fix(permissions): 🐛 Improved the security around the role "Manage Own Requests" (#4397)

* Secure ManageOwnRequests API paths

Fixes #4391

* Hide delete request option if user is not allowed

* Refactor CheckOwnRequests

* Fix deleteRequest test

* Improve performance and clean up code

* Fix manageOwnRequests check

* Refactor CheckCanManageRequest
pull/4400/head
sephrat 3 years ago committed by GitHub
parent 4410790bc0
commit 334a32bca4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -78,6 +78,32 @@ namespace Ombi.Core.Engine
return _dbTv; return _dbTv;
} }
protected async Task<RequestEngineResult> CheckCanManageRequest(BaseRequest request) {
var errorResult = new RequestEngineResult {
Result = false,
ErrorCode = ErrorCode.NoPermissions
};
var successResult = new RequestEngineResult { Result = true };
// Admins can always manage requests
var isAdmin = await IsInRole(OmbiRoles.PowerUser) || await IsInRole(OmbiRoles.Admin);
if (isAdmin) {
return successResult;
}
// Users with 'ManageOwnRequests' can only manage their own requests
var canManageOwnRequests = await IsInRole(OmbiRoles.ManageOwnRequests);
if (!canManageOwnRequests) {
return errorResult;
}
var isRequestedBySameUser = ( await GetUser() ).Id == request.RequestedUser?.Id;
if (isRequestedBySameUser) {
return successResult;
}
return errorResult;
}
public RequestCountModel RequestCount() public RequestCountModel RequestCount()
{ {
var movieQuery = MovieRepository.GetAll(); var movieQuery = MovieRepository.GetAll();

@ -18,7 +18,7 @@ namespace Ombi.Core.Engine
Task<int> GetTotal(); Task<int> GetTotal();
Task<RequestEngineResult> MarkAvailable(int modelId); Task<RequestEngineResult> MarkAvailable(int modelId);
Task<RequestEngineResult> MarkUnavailable(int modelId); Task<RequestEngineResult> MarkUnavailable(int modelId);
Task RemoveAlbumRequest(int requestId); Task<RequestEngineResult> RemoveAlbumRequest(int requestId);
Task<RequestEngineResult> RequestAlbum(MusicAlbumRequestViewModel model); Task<RequestEngineResult> RequestAlbum(MusicAlbumRequestViewModel model);
Task<IEnumerable<AlbumRequest>> SearchAlbumRequest(string search); Task<IEnumerable<AlbumRequest>> SearchAlbumRequest(string search);
Task<bool> UserHasRequest(string userId); Task<bool> UserHasRequest(string userId);

@ -14,7 +14,7 @@ namespace Ombi.Core.Engine.Interfaces
Task<IEnumerable<MovieRequests>> SearchMovieRequest(string search); Task<IEnumerable<MovieRequests>> SearchMovieRequest(string search);
Task<RequestEngineResult> RequestCollection(int collectionId, CancellationToken cancellationToken); Task<RequestEngineResult> RequestCollection(int collectionId, CancellationToken cancellationToken);
Task RemoveMovieRequest(int requestId); Task<RequestEngineResult> RemoveMovieRequest(int requestId);
Task RemoveAllMovieRequests(); Task RemoveAllMovieRequests();
Task<MovieRequests> GetRequest(int requestId); Task<MovieRequests> GetRequest(int requestId);
Task<MovieRequests> UpdateMovieRequest(MovieRequests request); Task<MovieRequests> UpdateMovieRequest(MovieRequests request);

@ -20,7 +20,7 @@ namespace Ombi.Core.Engine.Interfaces
Task<TvRequests> UpdateTvRequest(TvRequests request); Task<TvRequests> UpdateTvRequest(TvRequests request);
Task<IEnumerable<ChildRequests>> GetAllChldren(int tvId); Task<IEnumerable<ChildRequests>> GetAllChldren(int tvId);
Task<ChildRequests> UpdateChildRequest(ChildRequests request); Task<ChildRequests> UpdateChildRequest(ChildRequests request);
Task RemoveTvChild(int requestId); Task<RequestEngineResult> RemoveTvChild(int requestId);
Task<RequestEngineResult> ApproveChildRequest(int id); Task<RequestEngineResult> ApproveChildRequest(int id);
Task<IEnumerable<TvRequests>> GetRequestsLite(); Task<IEnumerable<TvRequests>> GetRequestsLite();
Task UpdateQualityProfile(int requestId, int profileId); Task UpdateQualityProfile(int requestId, int profileId);

@ -654,11 +654,20 @@ namespace Ombi.Core.Engine
/// </summary> /// </summary>
/// <param name="requestId">The request identifier.</param> /// <param name="requestId">The request identifier.</param>
/// <returns></returns> /// <returns></returns>
public async Task RemoveMovieRequest(int requestId) public async Task<RequestEngineResult> RemoveMovieRequest(int requestId)
{ {
var request = await MovieRepository.GetAll().FirstOrDefaultAsync(x => x.Id == requestId); var request = await MovieRepository.GetAll().FirstOrDefaultAsync(x => x.Id == requestId);
var result = await CheckCanManageRequest(request);
if (result.IsError)
return result;
await MovieRepository.Delete(request); await MovieRepository.Delete(request);
await _mediaCacheService.Purge(); await _mediaCacheService.Purge();
return new RequestEngineResult
{
Result = true,
};
} }
public async Task RemoveAllMovieRequests() public async Task RemoveAllMovieRequests()

@ -404,10 +404,20 @@ namespace Ombi.Core.Engine
/// </summary> /// </summary>
/// <param name="requestId">The request identifier.</param> /// <param name="requestId">The request identifier.</param>
/// <returns></returns> /// <returns></returns>
public async Task RemoveAlbumRequest(int requestId) public async Task<RequestEngineResult> RemoveAlbumRequest(int requestId)
{ {
var request = await MusicRepository.GetAll().FirstOrDefaultAsync(x => x.Id == requestId); var request = await MusicRepository.GetAll().FirstOrDefaultAsync(x => x.Id == requestId);
var result = await CheckCanManageRequest(request);
if (result.IsError)
return result;
await MusicRepository.Delete(request); await MusicRepository.Delete(request);
return new RequestEngineResult
{
Result = true,
};
} }
public async Task<bool> UserHasRequest(string userId) public async Task<bool> UserHasRequest(string userId)

@ -7,7 +7,7 @@ namespace Ombi.Core.Engine
{ {
public bool Result { get; set; } public bool Result { get; set; }
public string Message { get; set; } public string Message { get; set; }
public bool IsError => !string.IsNullOrEmpty(ErrorMessage); public bool IsError => ( !string.IsNullOrEmpty(ErrorMessage) || ErrorCode != null );
public string ErrorMessage { get; set; } public string ErrorMessage { get; set; }
public ErrorCode? ErrorCode { get; set; } public ErrorCode? ErrorCode { get; set; }
public int RequestId { get; set; } public int RequestId { get; set; }

@ -749,10 +749,14 @@ namespace Ombi.Core.Engine
return request; return request;
} }
public async Task RemoveTvChild(int requestId) public async Task<RequestEngineResult> RemoveTvChild(int requestId)
{ {
var request = await TvRepository.GetChild().FirstOrDefaultAsync(x => x.Id == requestId); var request = await TvRepository.GetChild().FirstOrDefaultAsync(x => x.Id == requestId);
var result = await CheckCanManageRequest(request);
if (result.IsError)
return result;
TvRepository.Db.ChildRequests.Remove(request); TvRepository.Db.ChildRequests.Remove(request);
var all = TvRepository.Db.TvRequests.Include(x => x.ChildRequests); var all = TvRepository.Db.TvRequests.Include(x => x.ChildRequests);
var parent = all.FirstOrDefault(x => x.Id == request.ParentRequestId); var parent = all.FirstOrDefault(x => x.Id == request.ParentRequestId);
@ -766,6 +770,11 @@ namespace Ombi.Core.Engine
await TvRepository.Db.SaveChangesAsync(); await TvRepository.Db.SaveChangesAsync();
await _mediaCacheService.Purge(); await _mediaCacheService.Purge();
return new RequestEngineResult
{
Result = true,
};
} }
public async Task RemoveTvRequest(int requestId) public async Task RemoveTvRequest(int requestId)

@ -60,7 +60,7 @@
<th mat-header-cell *matHeaderCellDef> </th> <th mat-header-cell *matHeaderCellDef> </th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<button mat-raised-button color="accent" [routerLink]="'/details/artist/' + element.foreignArtistId">{{ 'Requests.Details' | translate}}</button> <button mat-raised-button color="accent" [routerLink]="'/details/artist/' + element.foreignArtistId">{{ 'Requests.Details' | translate}}</button>
<button mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin || manageOwnRequests"> {{ 'Requests.Options' | translate}}</button> <button mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin || ( manageOwnRequests && element.requestedUser?.userName == userName )"> {{ 'Requests.Options' | translate}}</button>
</td> </td>
</ng-container> </ng-container>

@ -26,6 +26,7 @@ export class AlbumsGridComponent implements OnInit, AfterViewInit {
public defaultOrder: string = "desc"; public defaultOrder: string = "desc";
public currentFilter: RequestFilterType = RequestFilterType.All; public currentFilter: RequestFilterType = RequestFilterType.All;
public manageOwnRequests: boolean; public manageOwnRequests: boolean;
public userName: string;
public RequestFilter = RequestFilterType; public RequestFilter = RequestFilterType;
@ -43,6 +44,7 @@ export class AlbumsGridComponent implements OnInit, AfterViewInit {
constructor(private requestService: RequestServiceV2, private ref: ChangeDetectorRef, constructor(private requestService: RequestServiceV2, private ref: ChangeDetectorRef,
private auth: AuthService, private storageService: StorageService) { private auth: AuthService, private storageService: StorageService) {
this.userName = auth.claims().name;
} }
public ngOnInit() { public ngOnInit() {

@ -76,7 +76,7 @@
<th mat-header-cell *matHeaderCellDef> </th> <th mat-header-cell *matHeaderCellDef> </th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<button id="detailsButton{{element.id}}" mat-raised-button color="accent" [routerLink]="'/details/movie/' + element.theMovieDbId">{{ 'Requests.Details' | translate}}</button> <button id="detailsButton{{element.id}}" mat-raised-button color="accent" [routerLink]="'/details/movie/' + element.theMovieDbId">{{ 'Requests.Details' | translate}}</button>
<button id="optionsButton{{element.id}}" mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin || manageOwnRequests"> {{ 'Requests.Options' | translate}}</button> <button id="optionsButton{{element.id}}" mat-raised-button color="warn" (click)="openOptions(element)" *ngIf="isAdmin || ( manageOwnRequests && element.requestedUser?.userName == userName ) "> {{ 'Requests.Options' | translate}}</button>
</td> </td>
</ng-container> </ng-container>

@ -31,6 +31,7 @@ export class MoviesGridComponent implements OnInit, AfterViewInit {
public defaultOrder: string = "desc"; public defaultOrder: string = "desc";
public currentFilter: RequestFilterType = RequestFilterType.All; public currentFilter: RequestFilterType = RequestFilterType.All;
public selection = new SelectionModel<IMovieRequests>(true, []); public selection = new SelectionModel<IMovieRequests>(true, []);
public userName: string;
public RequestFilter = RequestFilterType; public RequestFilter = RequestFilterType;
@ -50,6 +51,7 @@ export class MoviesGridComponent implements OnInit, AfterViewInit {
private requestServiceV1: RequestService, private notification: NotificationService, private requestServiceV1: RequestService, private notification: NotificationService,
private translateService: TranslateService) { private translateService: TranslateService) {
this.userName = auth.claims().name;
} }
public ngOnInit() { public ngOnInit() {

@ -1,8 +1,10 @@
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MAT_BOTTOM_SHEET_DATA, MatBottomSheetRef } from '@angular/material/bottom-sheet'; import { MAT_BOTTOM_SHEET_DATA, MatBottomSheetRef } from '@angular/material/bottom-sheet';
import { RequestService } from '../../../services'; import { MessageService, RequestService } from '../../../services';
import { RequestType } from '../../../interfaces'; import { IRequestEngineResult, RequestType } from '../../../interfaces';
import { UpdateType } from '../../models/UpdateType'; import { UpdateType } from '../../models/UpdateType';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
@Component({ @Component({
selector: 'request-options', selector: 'request-options',
@ -13,21 +15,31 @@ export class RequestOptionsComponent {
public RequestType = RequestType; public RequestType = RequestType;
constructor(@Inject(MAT_BOTTOM_SHEET_DATA) public data: any, constructor(@Inject(MAT_BOTTOM_SHEET_DATA) public data: any,
private requestService: RequestService, private bottomSheetRef: MatBottomSheetRef<RequestOptionsComponent>) { } private requestService: RequestService,
private messageService: MessageService,
private bottomSheetRef: MatBottomSheetRef<RequestOptionsComponent>,
private translate: TranslateService) { }
public async delete() { public async delete() {
var request: Observable<IRequestEngineResult>;
if (this.data.type === RequestType.movie) { if (this.data.type === RequestType.movie) {
await this.requestService.removeMovieRequestAsync(this.data.id); request = this.requestService.removeMovieRequestAsync(this.data.id);
} }
if (this.data.type === RequestType.tvShow) { if (this.data.type === RequestType.tvShow) {
await this.requestService.deleteChild(this.data.id).toPromise(); request = this.requestService.deleteChild(this.data.id);
} }
if (this.data.type === RequestType.album) { if (this.data.type === RequestType.album) {
await this.requestService.removeAlbumRequest(this.data.id).toPromise(); request = this.requestService.removeAlbumRequest(this.data.id);
} }
request.subscribe(result => {
if (result.result) {
this.messageService.send(this.translate.instant("Requests.SuccessfullyDeleted"));
this.bottomSheetRef.dismiss({type: UpdateType.Delete}); this.bottomSheetRef.dismiss({type: UpdateType.Delete});
return; return;
} else {
this.messageService.sendRequestEngineResultError(result);
}
});
} }
public async approve() { public async approve() {

@ -73,8 +73,8 @@ export class RequestService extends ServiceHelpers {
this.http.delete(`${this.url}movie/${requestId}`, {headers: this.headers}).subscribe(); this.http.delete(`${this.url}movie/${requestId}`, {headers: this.headers}).subscribe();
} }
public removeMovieRequestAsync(requestId: number) { public removeMovieRequestAsync(requestId: number): Observable<IRequestEngineResult> {
return this.http.delete(`${this.url}movie/${requestId}`, {headers: this.headers}).toPromise(); return this.http.delete<IRequestEngineResult>(`${this.url}movie/${requestId}`, {headers: this.headers});
} }
public updateMovieRequest(request: IMovieRequests): Observable<IMovieRequests> { public updateMovieRequest(request: IMovieRequests): Observable<IMovieRequests> {
@ -129,8 +129,8 @@ export class RequestService extends ServiceHelpers {
return this.http.post<IRequestEngineResult>(`${this.url}tv/approve`, JSON.stringify(child), {headers: this.headers}); return this.http.post<IRequestEngineResult>(`${this.url}tv/approve`, JSON.stringify(child), {headers: this.headers});
} }
public deleteChild(childId: number): Observable<boolean> { public deleteChild(childId: number): Observable<IRequestEngineResult> {
return this.http.delete<boolean>(`${this.url}tv/child/${childId}`, {headers: this.headers}); return this.http.delete<IRequestEngineResult>(`${this.url}tv/child/${childId}`, {headers: this.headers});
} }
public subscribeToMovie(requestId: number): Observable<boolean> { public subscribeToMovie(requestId: number): Observable<boolean> {
@ -185,7 +185,7 @@ export class RequestService extends ServiceHelpers {
return this.http.get<IAlbumRequest[]>(`${this.url}music/search/${search}`, {headers: this.headers}); return this.http.get<IAlbumRequest[]>(`${this.url}music/search/${search}`, {headers: this.headers});
} }
public removeAlbumRequest(request: number): any { public removeAlbumRequest(request: number): Observable<IRequestEngineResult> {
return this.http.delete(`${this.url}music/${request}`, {headers: this.headers}); return this.http.delete<IRequestEngineResult>(`${this.url}music/${request}`, {headers: this.headers});
} }
} }

@ -113,9 +113,9 @@ namespace Ombi.Controllers.V1
/// <returns></returns> /// <returns></returns>
[HttpDelete("{requestId:int}")] [HttpDelete("{requestId:int}")]
[Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")] [Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")]
public async Task DeleteRequest(int requestId) public async Task<RequestEngineResult> DeleteRequest(int requestId)
{ {
await _engine.RemoveAlbumRequest(requestId); return await _engine.RemoveAlbumRequest(requestId);
} }
/// <summary> /// <summary>

@ -128,9 +128,9 @@ namespace Ombi.Controllers.V1
/// <returns></returns> /// <returns></returns>
[HttpDelete("movie/{requestId:int}")] [HttpDelete("movie/{requestId:int}")]
[Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")] [Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")]
public async Task DeleteRequest(int requestId) public async Task<RequestEngineResult> DeleteRequest(int requestId)
{ {
await MovieRequestEngine.RemoveMovieRequest(requestId); return await MovieRequestEngine.RemoveMovieRequest(requestId);
} }
/// <summary> /// <summary>
@ -324,7 +324,7 @@ namespace Ombi.Controllers.V1
/// <param name="requestId">The request identifier.</param> /// <param name="requestId">The request identifier.</param>
/// <returns></returns> /// <returns></returns>
[HttpDelete("tv/{requestId:int}")] [HttpDelete("tv/{requestId:int}")]
[Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")] [Authorize(Roles = "Admin,PowerUser")]
public async Task DeleteTvRequest(int requestId) public async Task DeleteTvRequest(int requestId)
{ {
await TvRequestEngine.RemoveTvRequest(requestId); await TvRequestEngine.RemoveTvRequest(requestId);
@ -437,10 +437,9 @@ namespace Ombi.Controllers.V1
/// <returns></returns> /// <returns></returns>
[Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")] [Authorize(Roles = "Admin,PowerUser,ManageOwnRequests")]
[HttpDelete("tv/child/{requestId:int}")] [HttpDelete("tv/child/{requestId:int}")]
public async Task<bool> DeleteChildRequest(int requestId) public async Task<RequestEngineResult> DeleteChildRequest(int requestId)
{ {
await TvRequestEngine.RemoveTvChild(requestId); return await TvRequestEngine.RemoveTvChild(requestId);
return true;
} }

@ -198,6 +198,7 @@
"Approved": "Successfully approved selected items" "Approved": "Successfully approved selected items"
}, },
"SuccessfullyApproved": "Successfully Approved", "SuccessfullyApproved": "Successfully Approved",
"SuccessfullyDeleted": "Request successfully deleted",
"NowAvailable": "Request is now available", "NowAvailable": "Request is now available",
"NowUnavailable": "Request is now unavailable", "NowUnavailable": "Request is now unavailable",
"SuccessfullyReprocessed": "Successfully Re-processed the request", "SuccessfullyReprocessed": "Successfully Re-processed the request",

@ -39,7 +39,7 @@ describe("Requests Tests", () => {
row.optionsDelete.click(); row.optionsDelete.click();
cy.wait('@deleteRequest').then((intercept) => { cy.wait('@deleteRequest').then((intercept) => {
expect(intercept.response.body).is.true; expect(intercept.response.body.result).is.true;
}) })
row.title.should('not.exist'); row.title.should('not.exist');

Loading…
Cancel
Save