mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-08 09:08:52 +00:00
Compare commits
18 Commits
l10n_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
|
||||
still have "object-level" permissions.
|
||||
|
||||
| Type | Details |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||
| Document | Add, edit, delete or view Documents. |
|
||||
| DocumentType | Add, edit, delete or view Document Types. |
|
||||
| Group | Add, edit, delete or view Groups. |
|
||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||
| Note | Add, edit, delete or view Notes. |
|
||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Add, delete or view Share Links. |
|
||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||
| Tag | Add, edit, delete or view Tags. |
|
||||
| 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). |
|
||||
| Type | Details |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||
| Document | Add, edit, delete or view Documents. |
|
||||
| DocumentType | Add, edit, delete or view Document Types. |
|
||||
| Group | Add, edit, delete or view Groups. |
|
||||
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||
| Note | Add, edit, delete or view Notes. |
|
||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||
| SavedView | Add, edit, delete or view Saved Views. |
|
||||
| ShareLink | Add, delete or view Share Links. |
|
||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||
| SystemStatus | View the system status dialog and corresponding API endpoint. Admin users also retain system status access. |
|
||||
| Tag | Add, edit, delete or view Tags. |
|
||||
| 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}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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>
|
||||
</button>
|
||||
@if (permissionsService.isAdmin()) {
|
||||
@if (canViewSystemStatus) {
|
||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||
[disabled]="!systemStatus">
|
||||
@if (!systemStatus) {
|
||||
@@ -26,6 +26,8 @@
|
||||
}
|
||||
<ng-container i18n>System Status</ng-container>
|
||||
</button>
|
||||
}
|
||||
@if (permissionsService.isAdmin()) {
|
||||
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||
<ng-container i18n>Open Django Admin</ng-container>
|
||||
<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 { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
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 { SavedViewService } from 'src/app/services/rest/saved-view.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', () => {
|
||||
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()
|
||||
expect(component['systemStatus']).toEqual(status) // private
|
||||
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||
@@ -344,7 +354,13 @@ describe('SettingsComponent', () => {
|
||||
it('should open system status dialog', () => {
|
||||
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||
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()
|
||||
component.showSystemStatus()
|
||||
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||
|
||||
@@ -429,7 +429,7 @@ export class SettingsComponent
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
|
||||
if (this.permissionsService.isAdmin()) {
|
||||
if (this.canViewSystemStatus) {
|
||||
this.systemStatusService.get().subscribe((status) => {
|
||||
this.systemStatus = status
|
||||
})
|
||||
@@ -647,6 +647,16 @@ export class SettingsComponent
|
||||
.setValue(Array.from(hiddenFields))
|
||||
}
|
||||
|
||||
public get canViewSystemStatus(): boolean {
|
||||
return (
|
||||
this.permissionsService.isAdmin() ||
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.SystemStatus
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
showSystemStatus() {
|
||||
const modal: NgbModalRef = this.modalService.open(
|
||||
SystemStatusDialogComponent,
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
</div>
|
||||
<div class="form-check form-switch form-check-inline">
|
||||
<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 class="form-check form-switch form-check-inline">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<label class="form-check-label visually-hidden" for="{{type}}_all" i18n>All</label>
|
||||
</div>
|
||||
@for (action of PermissionAction | keyvalue; track action) {
|
||||
<div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
|
||||
@for (action of PermissionAction | keyvalue: sortActions; track action.key) {
|
||||
<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}}">
|
||||
<label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}">{{action.key}}</label>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,6 @@ const inheritedPermissions = ['change_tag', 'view_documenttype']
|
||||
describe('PermissionsSelectComponent', () => {
|
||||
let component: PermissionsSelectComponent
|
||||
let fixture: ComponentFixture<PermissionsSelectComponent>
|
||||
let permissionsChangeResult: Permissions
|
||||
let settingsService: SettingsService
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -45,7 +44,7 @@ describe('PermissionsSelectComponent', () => {
|
||||
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
component.registerOnChange((r) => (permissionsChangeResult = r))
|
||||
component.registerOnChange((r) => r)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
@@ -75,7 +74,6 @@ describe('PermissionsSelectComponent', () => {
|
||||
it('should update on permissions set', () => {
|
||||
component.ngOnInit()
|
||||
component.writeValue(permissions)
|
||||
expect(permissionsChangeResult).toEqual(permissions)
|
||||
expect(component.typesWithAllActions).toContain('Document')
|
||||
})
|
||||
|
||||
@@ -92,13 +90,12 @@ describe('PermissionsSelectComponent', () => {
|
||||
it('disable checkboxes when permissions are inherited', () => {
|
||||
component.ngOnInit()
|
||||
component.inheritedPermissions = inheritedPermissions
|
||||
fixture.detectChanges()
|
||||
expect(component.isInherited('Document', 'Add')).toBeFalsy()
|
||||
expect(component.isInherited('Document')).toBeFalsy()
|
||||
expect(component.isInherited('Tag', 'Change')).toBeTruthy()
|
||||
const input1 = fixture.debugElement.query(By.css('input#Document_Add'))
|
||||
expect(input1.nativeElement.disabled).toBeFalsy()
|
||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||
expect(input2.nativeElement.disabled).toBeTruthy()
|
||||
expect(component.form.get('Document').get('Add').disabled).toBeFalsy()
|
||||
expect(component.form.get('Tag').get('Change').disabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should exclude history permissions if disabled', () => {
|
||||
@@ -107,4 +104,60 @@ describe('PermissionsSelectComponent', () => {
|
||||
component = fixture.componentInstance
|
||||
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 {
|
||||
AbstractControl,
|
||||
@@ -58,6 +58,13 @@ export class PermissionsSelectComponent
|
||||
|
||||
typesWithAllActions: Set<string> = new Set()
|
||||
|
||||
private readonly actionOrder = [
|
||||
PermissionAction.Add,
|
||||
PermissionAction.Change,
|
||||
PermissionAction.Delete,
|
||||
PermissionAction.View,
|
||||
]
|
||||
|
||||
_inheritedPermissions: string[] = []
|
||||
|
||||
@Input()
|
||||
@@ -86,7 +93,7 @@ export class PermissionsSelectComponent
|
||||
}
|
||||
this.allowedTypes.forEach((type) => {
|
||||
const control = new FormGroup({})
|
||||
for (const action in PermissionAction) {
|
||||
for (const action of Object.keys(PermissionAction)) {
|
||||
control.addControl(action, new FormControl(null))
|
||||
}
|
||||
this.form.addControl(type, control)
|
||||
@@ -106,18 +113,14 @@ export class PermissionsSelectComponent
|
||||
this.permissionsService.getPermissionKeys(permissionStr)
|
||||
|
||||
if (actionKey && typeKey) {
|
||||
if (this.form.get(typeKey)?.get(actionKey)) {
|
||||
this.form
|
||||
.get(typeKey)
|
||||
.get(actionKey)
|
||||
.patchValue(true, { emitEvent: false })
|
||||
}
|
||||
this.form
|
||||
.get(typeKey)
|
||||
?.get(actionKey)
|
||||
?.patchValue(true, { emitEvent: false })
|
||||
}
|
||||
})
|
||||
this.allowedTypes.forEach((type) => {
|
||||
if (
|
||||
Object.values(this.form.get(type).value).every((val) => val == true)
|
||||
) {
|
||||
if (this.typeHasAllActionsSelected(type)) {
|
||||
this.typesWithAllActions.add(type)
|
||||
} else {
|
||||
this.typesWithAllActions.delete(type)
|
||||
@@ -149,12 +152,16 @@ export class PermissionsSelectComponent
|
||||
this.form.valueChanges.subscribe((newValue) => {
|
||||
let permissions = []
|
||||
Object.entries(newValue).forEach(([typeKey, typeValue]) => {
|
||||
// e.g. [Document, { Add: true, View: true ... }]
|
||||
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(
|
||||
(PermissionType[typeKey] as string).replace(
|
||||
'%s',
|
||||
@@ -163,7 +170,7 @@ export class PermissionsSelectComponent
|
||||
)
|
||||
})
|
||||
|
||||
if (selectedActions.length == Object.entries(typeValue).length) {
|
||||
if (this.typeHasAllActionsSelected(typeKey)) {
|
||||
this.typesWithAllActions.add(typeKey)
|
||||
} else {
|
||||
this.typesWithAllActions.delete(typeKey)
|
||||
@@ -174,19 +181,23 @@ export class PermissionsSelectComponent
|
||||
permissions.filter((p) => !this._inheritedPermissions.includes(p))
|
||||
)
|
||||
})
|
||||
|
||||
this.updateDisabledStates()
|
||||
}
|
||||
|
||||
toggleAll(event, type) {
|
||||
const typeGroup = this.form.get(type)
|
||||
if (event.target.checked) {
|
||||
Object.keys(PermissionAction).forEach((action) => {
|
||||
typeGroup.get(action).patchValue(true)
|
||||
Object.keys(PermissionAction)
|
||||
.filter((action) =>
|
||||
this.isActionSupported(PermissionType[type], PermissionAction[action])
|
||||
)
|
||||
.forEach((action) => {
|
||||
typeGroup.get(action).patchValue(event.target.checked)
|
||||
})
|
||||
|
||||
if (this.typeHasAllActionsSelected(type)) {
|
||||
this.typesWithAllActions.add(type)
|
||||
} else {
|
||||
Object.keys(PermissionAction).forEach((action) => {
|
||||
typeGroup.get(action).patchValue(false)
|
||||
})
|
||||
this.typesWithAllActions.delete(type)
|
||||
}
|
||||
}
|
||||
@@ -201,14 +212,21 @@ export class PermissionsSelectComponent
|
||||
)
|
||||
)
|
||||
} else {
|
||||
return Object.values(PermissionAction).every((action) => {
|
||||
return this._inheritedPermissions.includes(
|
||||
this.permissionsService.getPermissionCode(
|
||||
action as PermissionAction,
|
||||
PermissionType[typeKey]
|
||||
return Object.keys(PermissionAction)
|
||||
.filter((action) =>
|
||||
this.isActionSupported(
|
||||
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) => {
|
||||
const control = this.form.get(type)
|
||||
let actionControl: AbstractControl
|
||||
for (const action in PermissionAction) {
|
||||
for (const action of Object.keys(PermissionAction)) {
|
||||
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
|
||||
? actionControl.disable()
|
||||
: actionControl.enable()
|
||||
? actionControl.disable({ emitEvent: false })
|
||||
: 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,
|
||||
} from './permissions.service'
|
||||
|
||||
const VIEW_ONLY_PERMISSION_TYPES = new Set<PermissionType>([
|
||||
PermissionType.GlobalStatistics,
|
||||
PermissionType.SystemStatus,
|
||||
])
|
||||
|
||||
describe('PermissionsService', () => {
|
||||
let permissionsService: PermissionsService
|
||||
|
||||
@@ -264,6 +269,8 @@ describe('PermissionsService', () => {
|
||||
'change_applicationconfiguration',
|
||||
'delete_applicationconfiguration',
|
||||
'view_applicationconfiguration',
|
||||
'view_global_statistics',
|
||||
'view_system_status',
|
||||
],
|
||||
{
|
||||
username: 'testuser',
|
||||
@@ -274,7 +281,10 @@ describe('PermissionsService', () => {
|
||||
|
||||
Object.values(PermissionType).forEach((type) => {
|
||||
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',
|
||||
Workflow = '%s_workflow',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
GlobalStatistics = '%s_global_statistics',
|
||||
SystemStatus = '%s_system_status',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
||||
@@ -56,6 +56,26 @@ class PaperlessAdminPermissions(BasePermission):
|
||||
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):
|
||||
ctype = ContentType.objects.get_for_model(obj)
|
||||
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
|
||||
non_superuser = User.objects.create_user(username="non_superuser")
|
||||
non_superuser.user_permissions.add(
|
||||
*Permission.objects.all(),
|
||||
*Permission.objects.exclude(codename="view_global_statistics"),
|
||||
)
|
||||
non_superuser.save()
|
||||
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.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:
|
||||
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
||||
id=str(uuid.uuid4()),
|
||||
|
||||
@@ -5,12 +5,14 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from celery import states
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import override_settings
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import PaperlessTask
|
||||
from documents.permissions import has_system_status_permission
|
||||
from paperless import version
|
||||
|
||||
|
||||
@@ -91,6 +93,22 @@ class TestSystemStatus(APITestCase):
|
||||
self.client.force_login(normal_user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
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:
|
||||
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 get_document_count_filter_for_user
|
||||
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_system_status_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.plugins.date_parsing import get_date_parser
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
@@ -3265,10 +3267,11 @@ class StatisticsView(GenericAPIView):
|
||||
|
||||
def get(self, request, format=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 = (
|
||||
Document.objects.all()
|
||||
if user is None
|
||||
if can_view_global_stats
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_document",
|
||||
@@ -3277,12 +3280,12 @@ class StatisticsView(GenericAPIView):
|
||||
)
|
||||
tags = (
|
||||
Tag.objects.all()
|
||||
if user is None
|
||||
if can_view_global_stats
|
||||
else get_objects_for_user_owner_aware(user, "documents.view_tag", Tag)
|
||||
).only("id", "is_inbox_tag")
|
||||
correspondent_count = (
|
||||
Correspondent.objects.count()
|
||||
if user is None
|
||||
if can_view_global_stats
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_correspondent",
|
||||
@@ -3291,7 +3294,7 @@ class StatisticsView(GenericAPIView):
|
||||
)
|
||||
document_type_count = (
|
||||
DocumentType.objects.count()
|
||||
if user is None
|
||||
if can_view_global_stats
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_documenttype",
|
||||
@@ -3300,7 +3303,7 @@ class StatisticsView(GenericAPIView):
|
||||
)
|
||||
storage_path_count = (
|
||||
StoragePath.objects.count()
|
||||
if user is None
|
||||
if can_view_global_stats
|
||||
else get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_storagepath",
|
||||
@@ -4257,7 +4260,7 @@ class SystemStatusView(PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
if not request.user.is_staff:
|
||||
if not has_system_status_permission(request.user):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
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:
|
||||
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
|
||||
return "ApplicationConfiguration"
|
||||
|
||||
Reference in New Issue
Block a user