mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-08 00:58:52 +00:00
Compare commits
18 Commits
dev
...
feature-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
928a3114b5 | ||
|
|
576fe373b5 | ||
|
|
b764d5c532 | ||
|
|
1bd2c502d9 | ||
|
|
b45877378e | ||
|
|
69746ff0a3 | ||
|
|
f65fb6292c | ||
|
|
b907af696e | ||
|
|
6193e02e3f | ||
|
|
226e9420bc | ||
|
|
93d7a746c4 | ||
|
|
a7b11d4d85 | ||
|
|
bb776a460a | ||
|
|
bbf29b4efd | ||
|
|
95a1dad0b7 | ||
|
|
fc7768c53a | ||
|
|
3d91e995bb | ||
|
|
f2ac4d763a |
@@ -398,25 +398,27 @@ Global permissions define what areas of the app and API endpoints users can acce
|
|||||||
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||||
still have "object-level" permissions.
|
still have "object-level" permissions.
|
||||||
|
|
||||||
| Type | Details |
|
| Type | Details |
|
||||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||||
| Document | Add, edit, delete or view Documents. |
|
| Document | Add, edit, delete or view Documents. |
|
||||||
| DocumentType | Add, edit, delete or view Document Types. |
|
| DocumentType | Add, edit, delete or view Document Types. |
|
||||||
| Group | Add, edit, delete or view Groups. |
|
| Group | Add, edit, delete or view Groups. |
|
||||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||||
| Note | Add, edit, delete or view Notes. |
|
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
| Note | Add, edit, delete or view Notes. |
|
||||||
| SavedView | Add, edit, delete or view Saved Views. |
|
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||||
| ShareLink | Add, delete or view Share Links. |
|
| SavedView | Add, edit, delete or view Saved Views. |
|
||||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
| ShareLink | Add, delete or view Share Links. |
|
||||||
| Tag | Add, edit, delete or view Tags. |
|
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
| SystemStatus | View the system status dialog and corresponding API endpoint. Admin users also retain system status access. |
|
||||||
| User | Add, edit, delete or view Users. |
|
| Tag | Add, edit, delete or view Tags. |
|
||||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||||
|
| User | Add, edit, delete or view Users. |
|
||||||
|
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||||
|
|
||||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||||
<i-bs class="me-2" name="airplane"></i-bs><ng-container i18n>Start tour</ng-container>
|
<i-bs class="me-2" name="airplane"></i-bs><ng-container i18n>Start tour</ng-container>
|
||||||
</button>
|
</button>
|
||||||
@if (permissionsService.isAdmin()) {
|
@if (canViewSystemStatus) {
|
||||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
[disabled]="!systemStatus">
|
[disabled]="!systemStatus">
|
||||||
@if (!systemStatus) {
|
@if (!systemStatus) {
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
}
|
}
|
||||||
<ng-container i18n>System Status</ng-container>
|
<ng-container i18n>System Status</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
@if (permissionsService.isAdmin()) {
|
||||||
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||||
<ng-container i18n>Open Django Admin</ng-container>
|
<ng-container i18n>Open Django Admin</ng-container>
|
||||||
<i-bs class="ms-2" name="arrow-up-right"></i-bs>
|
<i-bs class="ms-2" name="arrow-up-right"></i-bs>
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionType,
|
||||||
|
PermissionsService,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
@@ -328,7 +332,13 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should load system status on initialize, show errors if needed', () => {
|
it('should load system status on initialize, show errors if needed', () => {
|
||||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation(
|
||||||
|
(action, type) =>
|
||||||
|
action === PermissionAction.View &&
|
||||||
|
type === PermissionType.SystemStatus
|
||||||
|
)
|
||||||
completeSetup()
|
completeSetup()
|
||||||
expect(component['systemStatus']).toEqual(status) // private
|
expect(component['systemStatus']).toEqual(status) // private
|
||||||
expect(component.systemStatusHasErrors).toBeTruthy()
|
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||||
@@ -344,7 +354,13 @@ describe('SettingsComponent', () => {
|
|||||||
it('should open system status dialog', () => {
|
it('should open system status dialog', () => {
|
||||||
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation(
|
||||||
|
(action, type) =>
|
||||||
|
action === PermissionAction.View &&
|
||||||
|
type === PermissionType.SystemStatus
|
||||||
|
)
|
||||||
completeSetup()
|
completeSetup()
|
||||||
component.showSystemStatus()
|
component.showSystemStatus()
|
||||||
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.permissionsService.isAdmin()) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus = status
|
this.systemStatus = status
|
||||||
})
|
})
|
||||||
@@ -647,6 +647,16 @@ export class SettingsComponent
|
|||||||
.setValue(Array.from(hiddenFields))
|
.setValue(Array.from(hiddenFields))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get canViewSystemStatus(): boolean {
|
||||||
|
return (
|
||||||
|
this.permissionsService.isAdmin() ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.SystemStatus
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
showSystemStatus() {
|
showSystemStatus() {
|
||||||
const modal: NgbModalRef = this.modalService.open(
|
const modal: NgbModalRef = this.modalService.open(
|
||||||
SystemStatusDialogComponent,
|
SystemStatusDialogComponent,
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch form-check-inline">
|
<div class="form-check form-switch form-check-inline">
|
||||||
<input type="checkbox" class="form-check-input" id="is_staff" formControlName="is_staff">
|
<input type="checkbox" class="form-check-input" id="is_staff" formControlName="is_staff">
|
||||||
<label class="form-check-label" for="is_staff"><ng-container i18n>Admin</ng-container> <small class="form-text text-muted ms-1" i18n>Access logs, Django backend</small></label>
|
<label class="form-check-label" for="is_staff"><ng-container i18n>Admin</ng-container> <small class="form-text text-muted ms-1" i18n>Access system status, logs, Django backend</small></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch form-check-inline">
|
<div class="form-check form-switch form-check-inline">
|
||||||
<input type="checkbox" class="form-check-input" id="is_superuser" formControlName="is_superuser" (change)="onToggleSuperUser()">
|
<input type="checkbox" class="form-check-input" id="is_superuser" formControlName="is_superuser" (change)="onToggleSuperUser()">
|
||||||
<label class="form-check-label" for="is_superuser"><ng-container i18n>Superuser</ng-container> <small class="form-text text-muted ms-1" i18n>(Grants all permissions and can view objects)</small></label>
|
<label class="form-check-label" for="is_superuser"><ng-container i18n>Superuser</ng-container> <small class="form-text text-muted ms-1" i18n>Grants all permissions and can view all objects</small></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<input type="checkbox" class="form-check-input" id="{{type}}_all" (change)="toggleAll($event, type)" [checked]="typesWithAllActions.has(type) || isInherited(type)" [attr.disabled]="disabled || isInherited(type) ? true : null">
|
<input type="checkbox" class="form-check-input" id="{{type}}_all" (change)="toggleAll($event, type)" [checked]="typesWithAllActions.has(type) || isInherited(type)" [attr.disabled]="disabled || isInherited(type) ? true : null">
|
||||||
<label class="form-check-label visually-hidden" for="{{type}}_all" i18n>All</label>
|
<label class="form-check-label visually-hidden" for="{{type}}_all" i18n>All</label>
|
||||||
</div>
|
</div>
|
||||||
@for (action of PermissionAction | keyvalue; track action) {
|
@for (action of PermissionAction | keyvalue: sortActions; track action.key) {
|
||||||
<div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
|
<div class="col form-check form-check-inline" [class.invisible]="!isActionSupported(PermissionType[type], action.value)" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
|
||||||
<input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}">
|
<input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}">
|
||||||
<label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}">{{action.key}}</label>
|
<label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}">{{action.key}}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const inheritedPermissions = ['change_tag', 'view_documenttype']
|
|||||||
describe('PermissionsSelectComponent', () => {
|
describe('PermissionsSelectComponent', () => {
|
||||||
let component: PermissionsSelectComponent
|
let component: PermissionsSelectComponent
|
||||||
let fixture: ComponentFixture<PermissionsSelectComponent>
|
let fixture: ComponentFixture<PermissionsSelectComponent>
|
||||||
let permissionsChangeResult: Permissions
|
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -45,7 +44,7 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
||||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
component.registerOnChange((r) => (permissionsChangeResult = r))
|
component.registerOnChange((r) => r)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -75,7 +74,6 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
it('should update on permissions set', () => {
|
it('should update on permissions set', () => {
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.writeValue(permissions)
|
component.writeValue(permissions)
|
||||||
expect(permissionsChangeResult).toEqual(permissions)
|
|
||||||
expect(component.typesWithAllActions).toContain('Document')
|
expect(component.typesWithAllActions).toContain('Document')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -92,13 +90,12 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
it('disable checkboxes when permissions are inherited', () => {
|
it('disable checkboxes when permissions are inherited', () => {
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.inheritedPermissions = inheritedPermissions
|
component.inheritedPermissions = inheritedPermissions
|
||||||
|
fixture.detectChanges()
|
||||||
expect(component.isInherited('Document', 'Add')).toBeFalsy()
|
expect(component.isInherited('Document', 'Add')).toBeFalsy()
|
||||||
expect(component.isInherited('Document')).toBeFalsy()
|
expect(component.isInherited('Document')).toBeFalsy()
|
||||||
expect(component.isInherited('Tag', 'Change')).toBeTruthy()
|
expect(component.isInherited('Tag', 'Change')).toBeTruthy()
|
||||||
const input1 = fixture.debugElement.query(By.css('input#Document_Add'))
|
expect(component.form.get('Document').get('Add').disabled).toBeFalsy()
|
||||||
expect(input1.nativeElement.disabled).toBeFalsy()
|
expect(component.form.get('Tag').get('Change').disabled).toBeTruthy()
|
||||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
|
||||||
expect(input2.nativeElement.disabled).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should exclude history permissions if disabled', () => {
|
it('should exclude history permissions if disabled', () => {
|
||||||
@@ -107,4 +104,60 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
expect(component.allowedTypes).not.toContain('History')
|
expect(component.allowedTypes).not.toContain('History')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should treat global statistics as view-only', () => {
|
||||||
|
component.ngOnInit()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.isActionSupported(
|
||||||
|
PermissionType.GlobalStatistics,
|
||||||
|
PermissionAction.View
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isActionSupported(
|
||||||
|
PermissionType.GlobalStatistics,
|
||||||
|
PermissionAction.Add
|
||||||
|
)
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
const addInput = fixture.debugElement.query(
|
||||||
|
By.css('input#GlobalStatistics_Add')
|
||||||
|
)
|
||||||
|
const viewInput = fixture.debugElement.query(
|
||||||
|
By.css('input#GlobalStatistics_View')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(addInput.nativeElement.disabled).toBeTruthy()
|
||||||
|
expect(viewInput.nativeElement.disabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should treat system status as view-only', () => {
|
||||||
|
component.ngOnInit()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.isActionSupported(
|
||||||
|
PermissionType.SystemStatus,
|
||||||
|
PermissionAction.View
|
||||||
|
)
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isActionSupported(
|
||||||
|
PermissionType.SystemStatus,
|
||||||
|
PermissionAction.Change
|
||||||
|
)
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
const changeInput = fixture.debugElement.query(
|
||||||
|
By.css('input#SystemStatus_Change')
|
||||||
|
)
|
||||||
|
const viewInput = fixture.debugElement.query(
|
||||||
|
By.css('input#SystemStatus_View')
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(changeInput.nativeElement.disabled).toBeTruthy()
|
||||||
|
expect(viewInput.nativeElement.disabled).toBeFalsy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { KeyValuePipe } from '@angular/common'
|
import { KeyValue, KeyValuePipe } from '@angular/common'
|
||||||
import { Component, forwardRef, inject, Input, OnInit } from '@angular/core'
|
import { Component, forwardRef, inject, Input, OnInit } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
@@ -58,6 +58,13 @@ export class PermissionsSelectComponent
|
|||||||
|
|
||||||
typesWithAllActions: Set<string> = new Set()
|
typesWithAllActions: Set<string> = new Set()
|
||||||
|
|
||||||
|
private readonly actionOrder = [
|
||||||
|
PermissionAction.Add,
|
||||||
|
PermissionAction.Change,
|
||||||
|
PermissionAction.Delete,
|
||||||
|
PermissionAction.View,
|
||||||
|
]
|
||||||
|
|
||||||
_inheritedPermissions: string[] = []
|
_inheritedPermissions: string[] = []
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
@@ -86,7 +93,7 @@ export class PermissionsSelectComponent
|
|||||||
}
|
}
|
||||||
this.allowedTypes.forEach((type) => {
|
this.allowedTypes.forEach((type) => {
|
||||||
const control = new FormGroup({})
|
const control = new FormGroup({})
|
||||||
for (const action in PermissionAction) {
|
for (const action of Object.keys(PermissionAction)) {
|
||||||
control.addControl(action, new FormControl(null))
|
control.addControl(action, new FormControl(null))
|
||||||
}
|
}
|
||||||
this.form.addControl(type, control)
|
this.form.addControl(type, control)
|
||||||
@@ -106,18 +113,14 @@ export class PermissionsSelectComponent
|
|||||||
this.permissionsService.getPermissionKeys(permissionStr)
|
this.permissionsService.getPermissionKeys(permissionStr)
|
||||||
|
|
||||||
if (actionKey && typeKey) {
|
if (actionKey && typeKey) {
|
||||||
if (this.form.get(typeKey)?.get(actionKey)) {
|
this.form
|
||||||
this.form
|
.get(typeKey)
|
||||||
.get(typeKey)
|
?.get(actionKey)
|
||||||
.get(actionKey)
|
?.patchValue(true, { emitEvent: false })
|
||||||
.patchValue(true, { emitEvent: false })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this.allowedTypes.forEach((type) => {
|
this.allowedTypes.forEach((type) => {
|
||||||
if (
|
if (this.typeHasAllActionsSelected(type)) {
|
||||||
Object.values(this.form.get(type).value).every((val) => val == true)
|
|
||||||
) {
|
|
||||||
this.typesWithAllActions.add(type)
|
this.typesWithAllActions.add(type)
|
||||||
} else {
|
} else {
|
||||||
this.typesWithAllActions.delete(type)
|
this.typesWithAllActions.delete(type)
|
||||||
@@ -149,12 +152,16 @@ export class PermissionsSelectComponent
|
|||||||
this.form.valueChanges.subscribe((newValue) => {
|
this.form.valueChanges.subscribe((newValue) => {
|
||||||
let permissions = []
|
let permissions = []
|
||||||
Object.entries(newValue).forEach(([typeKey, typeValue]) => {
|
Object.entries(newValue).forEach(([typeKey, typeValue]) => {
|
||||||
// e.g. [Document, { Add: true, View: true ... }]
|
|
||||||
const selectedActions = Object.entries(typeValue).filter(
|
const selectedActions = Object.entries(typeValue).filter(
|
||||||
([actionKey, actionValue]) => actionValue == true
|
([actionKey, actionValue]) =>
|
||||||
|
actionValue &&
|
||||||
|
this.isActionSupported(
|
||||||
|
PermissionType[typeKey],
|
||||||
|
PermissionAction[actionKey]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
selectedActions.forEach(([actionKey, actionValue]) => {
|
selectedActions.forEach(([actionKey]) => {
|
||||||
permissions.push(
|
permissions.push(
|
||||||
(PermissionType[typeKey] as string).replace(
|
(PermissionType[typeKey] as string).replace(
|
||||||
'%s',
|
'%s',
|
||||||
@@ -163,7 +170,7 @@ export class PermissionsSelectComponent
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (selectedActions.length == Object.entries(typeValue).length) {
|
if (this.typeHasAllActionsSelected(typeKey)) {
|
||||||
this.typesWithAllActions.add(typeKey)
|
this.typesWithAllActions.add(typeKey)
|
||||||
} else {
|
} else {
|
||||||
this.typesWithAllActions.delete(typeKey)
|
this.typesWithAllActions.delete(typeKey)
|
||||||
@@ -174,19 +181,23 @@ export class PermissionsSelectComponent
|
|||||||
permissions.filter((p) => !this._inheritedPermissions.includes(p))
|
permissions.filter((p) => !this._inheritedPermissions.includes(p))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.updateDisabledStates()
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAll(event, type) {
|
toggleAll(event, type) {
|
||||||
const typeGroup = this.form.get(type)
|
const typeGroup = this.form.get(type)
|
||||||
if (event.target.checked) {
|
Object.keys(PermissionAction)
|
||||||
Object.keys(PermissionAction).forEach((action) => {
|
.filter((action) =>
|
||||||
typeGroup.get(action).patchValue(true)
|
this.isActionSupported(PermissionType[type], PermissionAction[action])
|
||||||
|
)
|
||||||
|
.forEach((action) => {
|
||||||
|
typeGroup.get(action).patchValue(event.target.checked)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (this.typeHasAllActionsSelected(type)) {
|
||||||
this.typesWithAllActions.add(type)
|
this.typesWithAllActions.add(type)
|
||||||
} else {
|
} else {
|
||||||
Object.keys(PermissionAction).forEach((action) => {
|
|
||||||
typeGroup.get(action).patchValue(false)
|
|
||||||
})
|
|
||||||
this.typesWithAllActions.delete(type)
|
this.typesWithAllActions.delete(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,14 +212,21 @@ export class PermissionsSelectComponent
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return Object.values(PermissionAction).every((action) => {
|
return Object.keys(PermissionAction)
|
||||||
return this._inheritedPermissions.includes(
|
.filter((action) =>
|
||||||
this.permissionsService.getPermissionCode(
|
this.isActionSupported(
|
||||||
action as PermissionAction,
|
PermissionType[typeKey],
|
||||||
PermissionType[typeKey]
|
PermissionAction[action]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
.every((action) => {
|
||||||
|
return this._inheritedPermissions.includes(
|
||||||
|
this.permissionsService.getPermissionCode(
|
||||||
|
PermissionAction[action],
|
||||||
|
PermissionType[typeKey]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,12 +234,55 @@ export class PermissionsSelectComponent
|
|||||||
this.allowedTypes.forEach((type) => {
|
this.allowedTypes.forEach((type) => {
|
||||||
const control = this.form.get(type)
|
const control = this.form.get(type)
|
||||||
let actionControl: AbstractControl
|
let actionControl: AbstractControl
|
||||||
for (const action in PermissionAction) {
|
for (const action of Object.keys(PermissionAction)) {
|
||||||
actionControl = control.get(action)
|
actionControl = control.get(action)
|
||||||
|
if (
|
||||||
|
!this.isActionSupported(
|
||||||
|
PermissionType[type],
|
||||||
|
PermissionAction[action]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
actionControl.patchValue(false, { emitEvent: false })
|
||||||
|
actionControl.disable({ emitEvent: false })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
this.isInherited(type, action) || this.disabled
|
this.isInherited(type, action) || this.disabled
|
||||||
? actionControl.disable()
|
? actionControl.disable({ emitEvent: false })
|
||||||
: actionControl.enable()
|
: actionControl.enable({ emitEvent: false })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isActionSupported(
|
||||||
|
type: PermissionType,
|
||||||
|
action: PermissionAction
|
||||||
|
): boolean {
|
||||||
|
// Global statistics and system status only support view permissions
|
||||||
|
if (
|
||||||
|
type === PermissionType.GlobalStatistics ||
|
||||||
|
type === PermissionType.SystemStatus
|
||||||
|
) {
|
||||||
|
return action === PermissionAction.View
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private typeHasAllActionsSelected(typeKey: string): boolean {
|
||||||
|
return Object.keys(PermissionAction)
|
||||||
|
.filter((action) =>
|
||||||
|
this.isActionSupported(
|
||||||
|
PermissionType[typeKey],
|
||||||
|
PermissionAction[action]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.every((action) => !!this.form.get(typeKey)?.get(action)?.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public sortActions = (
|
||||||
|
a: KeyValue<string, PermissionAction>,
|
||||||
|
b: KeyValue<string, PermissionAction>
|
||||||
|
): number =>
|
||||||
|
this.actionOrder.indexOf(a.value) - this.actionOrder.indexOf(b.value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
PermissionsService,
|
PermissionsService,
|
||||||
} from './permissions.service'
|
} from './permissions.service'
|
||||||
|
|
||||||
|
const VIEW_ONLY_PERMISSION_TYPES = new Set<PermissionType>([
|
||||||
|
PermissionType.GlobalStatistics,
|
||||||
|
PermissionType.SystemStatus,
|
||||||
|
])
|
||||||
|
|
||||||
describe('PermissionsService', () => {
|
describe('PermissionsService', () => {
|
||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
|
|
||||||
@@ -264,6 +269,8 @@ describe('PermissionsService', () => {
|
|||||||
'change_applicationconfiguration',
|
'change_applicationconfiguration',
|
||||||
'delete_applicationconfiguration',
|
'delete_applicationconfiguration',
|
||||||
'view_applicationconfiguration',
|
'view_applicationconfiguration',
|
||||||
|
'view_global_statistics',
|
||||||
|
'view_system_status',
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
@@ -274,7 +281,10 @@ describe('PermissionsService', () => {
|
|||||||
|
|
||||||
Object.values(PermissionType).forEach((type) => {
|
Object.values(PermissionType).forEach((type) => {
|
||||||
Object.values(PermissionAction).forEach((action) => {
|
Object.values(PermissionAction).forEach((action) => {
|
||||||
expect(permissionsService.currentUserCan(action, type)).toBeTruthy()
|
expect(permissionsService.currentUserCan(action, type)).toBe(
|
||||||
|
!VIEW_ONLY_PERMISSION_TYPES.has(type) ||
|
||||||
|
action === PermissionAction.View
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export enum PermissionType {
|
|||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
ProcessedMail = '%s_processedmail',
|
ProcessedMail = '%s_processedmail',
|
||||||
|
GlobalStatistics = '%s_global_statistics',
|
||||||
|
SystemStatus = '%s_system_status',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ class PaperlessAdminPermissions(BasePermission):
|
|||||||
return request.user.is_staff
|
return request.user.is_staff
|
||||||
|
|
||||||
|
|
||||||
|
def has_global_statistics_permission(user: User | None) -> bool:
|
||||||
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return getattr(user, "is_superuser", False) or user.has_perm(
|
||||||
|
"paperless.view_global_statistics",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_system_status_permission(user: User | None) -> bool:
|
||||||
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return (
|
||||||
|
getattr(user, "is_superuser", False)
|
||||||
|
or getattr(user, "is_staff", False)
|
||||||
|
or user.has_perm("paperless.view_system_status")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_groups_with_only_permission(obj, codename):
|
def get_groups_with_only_permission(obj, codename):
|
||||||
ctype = ContentType.objects.get_for_model(obj)
|
ctype = ContentType.objects.get_for_model(obj)
|
||||||
permission = Permission.objects.get(content_type=ctype, codename=codename)
|
permission = Permission.objects.get(content_type=ctype, codename=codename)
|
||||||
|
|||||||
@@ -1309,7 +1309,7 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase):
|
|||||||
# Test as user without access to the document
|
# Test as user without access to the document
|
||||||
non_superuser = User.objects.create_user(username="non_superuser")
|
non_superuser = User.objects.create_user(username="non_superuser")
|
||||||
non_superuser.user_permissions.add(
|
non_superuser.user_permissions.add(
|
||||||
*Permission.objects.all(),
|
*Permission.objects.exclude(codename="view_global_statistics"),
|
||||||
)
|
)
|
||||||
non_superuser.save()
|
non_superuser.save()
|
||||||
self.client.force_authenticate(user=non_superuser)
|
self.client.force_authenticate(user=non_superuser)
|
||||||
|
|||||||
@@ -1314,6 +1314,41 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["documents_inbox"], 0)
|
self.assertEqual(response.data["documents_inbox"], 0)
|
||||||
|
|
||||||
|
def test_statistics_with_statistics_permission(self) -> None:
|
||||||
|
owner = User.objects.create_user("owner")
|
||||||
|
stats_user = User.objects.create_user("stats-user")
|
||||||
|
stats_user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_global_statistics"),
|
||||||
|
)
|
||||||
|
|
||||||
|
inbox_tag = Tag.objects.create(
|
||||||
|
name="stats_inbox",
|
||||||
|
is_inbox_tag=True,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
title="owned-doc",
|
||||||
|
checksum="stats-A",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="abcdef",
|
||||||
|
owner=owner,
|
||||||
|
).tags.add(inbox_tag)
|
||||||
|
Correspondent.objects.create(name="stats-correspondent", owner=owner)
|
||||||
|
DocumentType.objects.create(name="stats-type", owner=owner)
|
||||||
|
StoragePath.objects.create(name="stats-path", path="archive", owner=owner)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=stats_user)
|
||||||
|
response = self.client.get("/api/statistics/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["documents_total"], 1)
|
||||||
|
self.assertEqual(response.data["documents_inbox"], 1)
|
||||||
|
self.assertEqual(response.data["inbox_tags"], [inbox_tag.pk])
|
||||||
|
self.assertEqual(response.data["character_count"], 6)
|
||||||
|
self.assertEqual(response.data["correspondent_count"], 1)
|
||||||
|
self.assertEqual(response.data["document_type_count"], 1)
|
||||||
|
self.assertEqual(response.data["storage_path_count"], 1)
|
||||||
|
|
||||||
def test_upload(self) -> None:
|
def test_upload(self) -> None:
|
||||||
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ from pathlib import Path
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from celery import states
|
from celery import states
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
|
from documents.permissions import has_system_status_permission
|
||||||
from paperless import version
|
from paperless import version
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +93,22 @@ class TestSystemStatus(APITestCase):
|
|||||||
self.client.force_login(normal_user)
|
self.client.force_login(normal_user)
|
||||||
response = self.client.get(self.ENDPOINT)
|
response = self.client.get(self.ENDPOINT)
|
||||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
# test the permission helper function directly for good measure
|
||||||
|
self.assertFalse(has_system_status_permission(None))
|
||||||
|
|
||||||
|
def test_system_status_with_system_status_permission(self) -> None:
|
||||||
|
response = self.client.get(self.ENDPOINT)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
user = User.objects.create_user(username="status_user")
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_system_status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(self.ENDPOINT)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
def test_system_status_with_bad_basic_auth_challenges(self) -> None:
|
def test_system_status_with_bad_basic_auth_challenges(self) -> None:
|
||||||
self.client.credentials(HTTP_AUTHORIZATION="Basic invalid")
|
self.client.credentials(HTTP_AUTHORIZATION="Basic invalid")
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ from documents.permissions import ViewDocumentsPermissions
|
|||||||
from documents.permissions import annotate_document_count_for_related_queryset
|
from documents.permissions import annotate_document_count_for_related_queryset
|
||||||
from documents.permissions import get_document_count_filter_for_user
|
from documents.permissions import get_document_count_filter_for_user
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
|
from documents.permissions import has_global_statistics_permission
|
||||||
from documents.permissions import has_perms_owner_aware
|
from documents.permissions import has_perms_owner_aware
|
||||||
|
from documents.permissions import has_system_status_permission
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
@@ -3265,10 +3267,11 @@ class StatisticsView(GenericAPIView):
|
|||||||
|
|
||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
user = request.user if request.user is not None else None
|
user = request.user if request.user is not None else None
|
||||||
|
can_view_global_stats = has_global_statistics_permission(user) or user is None
|
||||||
|
|
||||||
documents = (
|
documents = (
|
||||||
Document.objects.all()
|
Document.objects.all()
|
||||||
if user is None
|
if can_view_global_stats
|
||||||
else get_objects_for_user_owner_aware(
|
else get_objects_for_user_owner_aware(
|
||||||
user,
|
user,
|
||||||
"documents.view_document",
|
"documents.view_document",
|
||||||
@@ -3277,12 +3280,12 @@ class StatisticsView(GenericAPIView):
|
|||||||
)
|
)
|
||||||
tags = (
|
tags = (
|
||||||
Tag.objects.all()
|
Tag.objects.all()
|
||||||
if user is None
|
if can_view_global_stats
|
||||||
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||||
).only("id", "is_inbox_tag")
|
).only("id", "is_inbox_tag")
|
||||||
correspondent_count = (
|
correspondent_count = (
|
||||||
Correspondent.objects.count()
|
Correspondent.objects.count()
|
||||||
if user is None
|
if can_view_global_stats
|
||||||
else get_objects_for_user_owner_aware(
|
else get_objects_for_user_owner_aware(
|
||||||
user,
|
user,
|
||||||
"documents.view_correspondent",
|
"documents.view_correspondent",
|
||||||
@@ -3291,7 +3294,7 @@ class StatisticsView(GenericAPIView):
|
|||||||
)
|
)
|
||||||
document_type_count = (
|
document_type_count = (
|
||||||
DocumentType.objects.count()
|
DocumentType.objects.count()
|
||||||
if user is None
|
if can_view_global_stats
|
||||||
else get_objects_for_user_owner_aware(
|
else get_objects_for_user_owner_aware(
|
||||||
user,
|
user,
|
||||||
"documents.view_documenttype",
|
"documents.view_documenttype",
|
||||||
@@ -3300,7 +3303,7 @@ class StatisticsView(GenericAPIView):
|
|||||||
)
|
)
|
||||||
storage_path_count = (
|
storage_path_count = (
|
||||||
StoragePath.objects.count()
|
StoragePath.objects.count()
|
||||||
if user is None
|
if can_view_global_stats
|
||||||
else get_objects_for_user_owner_aware(
|
else get_objects_for_user_owner_aware(
|
||||||
user,
|
user,
|
||||||
"documents.view_storagepath",
|
"documents.view_storagepath",
|
||||||
@@ -4257,7 +4260,7 @@ class SystemStatusView(PassUserMixin):
|
|||||||
permission_classes = (IsAuthenticated,)
|
permission_classes = (IsAuthenticated,)
|
||||||
|
|
||||||
def get(self, request, format=None):
|
def get(self, request, format=None):
|
||||||
if not request.user.is_staff:
|
if not has_system_status_permission(request.user):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
current_version = version.__full_version_str__
|
current_version = version.__full_version_str__
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-07 23:13
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("paperless", "0008_replace_skip_archive_file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="applicationconfiguration",
|
||||||
|
options={
|
||||||
|
"permissions": [
|
||||||
|
("view_global_statistics", "Can view global object counts"),
|
||||||
|
("view_system_status", "Can view system status information"),
|
||||||
|
],
|
||||||
|
"verbose_name": "paperless application settings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -341,6 +341,10 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("paperless application settings")
|
verbose_name = _("paperless application settings")
|
||||||
|
permissions = [
|
||||||
|
("view_global_statistics", "Can view global object counts"),
|
||||||
|
("view_system_status", "Can view system status information"),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self) -> str: # pragma: no cover
|
def __str__(self) -> str: # pragma: no cover
|
||||||
return "ApplicationConfiguration"
|
return "ApplicationConfiguration"
|
||||||
|
|||||||
Reference in New Issue
Block a user