Compare commits

..

18 Commits

Author SHA1 Message Date
shamoon
928a3114b5 Docs 2026-04-07 16:23:20 -07:00
shamoon
576fe373b5 Update permissions.py 2026-04-07 16:17:09 -07:00
shamoon
b764d5c532 On second thought, maybe move them here 2026-04-07 16:14:08 -07:00
shamoon
1bd2c502d9 Update test_api_status.py 2026-04-07 16:09:32 -07:00
shamoon
b45877378e Fair nuff sonar 2026-04-07 16:06:23 -07:00
shamoon
69746ff0a3 cleanup 2026-04-07 16:00:36 -07:00
shamoon
f65fb6292c Respect new system status perms in UI too 2026-04-07 15:44:12 -07:00
shamoon
b907af696e Clarify this in dialgo 2026-04-07 15:43:16 -07:00
shamoon
6193e02e3f DRY 2026-04-07 15:39:43 -07:00
shamoon
226e9420bc Fix this test 2026-04-07 15:36:47 -07:00
shamoon
93d7a746c4 Add separate system status permission 2026-04-07 15:35:44 -07:00
shamoon
a7b11d4d85 Restore this, global stats shouldnt apply here 2026-04-07 15:35:18 -07:00
shamoon
bb776a460a Update permissions-select.component.ts 2026-04-07 15:25:10 -07:00
shamoon
bbf29b4efd Include on frontend 2026-04-07 15:21:24 -07:00
shamoon
95a1dad0b7 More rename 2026-04-07 13:15:52 -07:00
shamoon
fc7768c53a Rename 2026-04-07 13:09:58 -07:00
shamoon
3d91e995bb Fix test by making this specific like it should be 2026-04-06 23:17:36 -07:00
shamoon
f2ac4d763a Enhacement: add permission for viewing stats 2026-04-06 22:48:50 -07:00
17 changed files with 331 additions and 73 deletions

View File

@@ -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}

View File

@@ -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>

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
})
}) })

View File

@@ -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)
} }

View File

@@ -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
)
}) })
}) })

View File

@@ -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({

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()),

View File

@@ -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")

View File

@@ -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__

View File

@@ -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",
},
),
]

View File

@@ -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"