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
26 changed files with 399 additions and 162 deletions

View File

@@ -164,8 +164,6 @@ updates:
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
cooldown:
default-days: 7
groups:
pre-commit-dependencies:
patterns:

View File

@@ -30,13 +30,10 @@ jobs:
persist-credentials: false
- name: Decide run mode
id: force
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
run: |
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
elif [[ "${EVENT_NAME}" == "push" && ( "${REF_NAME}" == "main" || "${REF_NAME}" == "dev" ) ]]; then
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
else
echo "run_all=false" >> "$GITHUB_OUTPUT"
@@ -44,22 +41,15 @@ jobs:
- name: Set diff range
id: range
if: steps.force.outputs.run_all != 'true'
env:
BEFORE_SHA: ${{ github.event.before }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EVENT_CREATED: ${{ github.event.created }}
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
SHA: ${{ github.sha }}
run: |
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
echo "base=${PR_BASE_SHA}" >> "$GITHUB_OUTPUT"
elif [[ "${EVENT_CREATED}" == "true" ]]; then
echo "base=${DEFAULT_BRANCH}" >> "$GITHUB_OUTPUT"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event.created }}" == "true" ]]; then
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
else
echo "base=${BEFORE_SHA}" >> "$GITHUB_OUTPUT"
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
fi
echo "ref=${SHA}" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
@@ -114,11 +104,9 @@ jobs:
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml
- name: Install Python dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
uv sync \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--frozen
- name: List installed Python dependencies
@@ -126,15 +114,14 @@ jobs:
uv pip list
- name: Install NLTK data
run: |
uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d "${NLTK_DATA}"
uv run python -m nltk.downloader punkt punkt_tab snowball_data stopwords -d ${{ env.NLTK_DATA }}
- name: Run tests
env:
NLTK_DATA: ${{ env.NLTK_DATA }}
PAPERLESS_CI_TEST: 1
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
uv run \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
pytest
@@ -182,11 +169,9 @@ jobs:
enable-cache: true
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
uv sync \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
--group testing \
--group typing \
--frozen
@@ -222,23 +207,19 @@ jobs:
runs-on: ubuntu-slim
steps:
- name: Check gate
env:
BACKEND_CHANGED: ${{ needs.changes.outputs.backend_changed }}
TEST_RESULT: ${{ needs.test.result }}
TYPING_RESULT: ${{ needs.typing.result }}
run: |
if [[ "${BACKEND_CHANGED}" != "true" ]]; then
if [[ "${{ needs.changes.outputs.backend_changed }}" != "true" ]]; then
echo "No backend-relevant changes detected."
exit 0
fi
if [[ "${TEST_RESULT}" != "success" ]]; then
echo "::error::Backend test job result: ${TEST_RESULT}"
if [[ "${{ needs.test.result }}" != "success" ]]; then
echo "::error::Backend test job result: ${{ needs.test.result }}"
exit 1
fi
if [[ "${TYPING_RESULT}" != "success" ]]; then
echo "::error::Backend typing job result: ${TYPING_RESULT}"
if [[ "${{ needs.typing.result }}" != "success" ]]; then
echo "::error::Backend typing job result: ${{ needs.typing.result }}"
exit 1
fi

View File

@@ -166,7 +166,6 @@ jobs:
runs-on: ubuntu-24.04
needs: build-arch
if: needs.build-arch.outputs.should-push == 'true'
environment: image-publishing
permissions:
contents: read
packages: write

View File

@@ -27,13 +27,10 @@ jobs:
persist-credentials: false
- name: Decide run mode
id: force
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
run: |
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
elif [[ "${EVENT_NAME}" == "push" && ( "${REF_NAME}" == "main" || "${REF_NAME}" == "dev" ) ]]; then
elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "dev" ) ]]; then
echo "run_all=true" >> "$GITHUB_OUTPUT"
else
echo "run_all=false" >> "$GITHUB_OUTPUT"
@@ -41,22 +38,15 @@ jobs:
- name: Set diff range
id: range
if: steps.force.outputs.run_all != 'true'
env:
BEFORE_SHA: ${{ github.event.before }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EVENT_CREATED: ${{ github.event.created }}
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
SHA: ${{ github.sha }}
run: |
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
echo "base=${PR_BASE_SHA}" >> "$GITHUB_OUTPUT"
elif [[ "${EVENT_CREATED}" == "true" ]]; then
echo "base=${DEFAULT_BRANCH}" >> "$GITHUB_OUTPUT"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event.created }}" == "true" ]]; then
echo "base=${{ github.event.repository.default_branch }}" >> "$GITHUB_OUTPUT"
else
echo "base=${BEFORE_SHA}" >> "$GITHUB_OUTPUT"
echo "base=${{ github.event.before }}" >> "$GITHUB_OUTPUT"
fi
echo "ref=${SHA}" >> "$GITHUB_OUTPUT"
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
@@ -234,7 +224,6 @@ jobs:
needs: [changes, unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04
environment: bundle-analysis
permissions:
contents: read
steps:

View File

@@ -64,21 +64,17 @@ jobs:
enable-cache: false
python-version: ${{ steps.setup-python.outputs.python-version }}
- name: Install Python dependencies
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
uv sync --python "${PYTHON_VERSION}" --dev --frozen
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext liblept5
# ---- Build Documentation ----
- name: Build documentation
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
uv run \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
--frozen \
zensical build --clean
@@ -87,20 +83,16 @@ jobs:
run: |
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
cd src/
uv run \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py compilemessages
- name: Collect static files
env:
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
run: |
cd src/
uv run \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
manage.py collectstatic --no-input --clear
- name: Assemble release package
run: |
@@ -218,13 +210,9 @@ jobs:
working-directory: docs
env:
CHANGELOG: ${{ needs.publish-release.outputs.changelog }}
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
VERSION: ${{ needs.publish-release.outputs.version }}
run: |
branch_name="${VERSION}-changelog"
git branch "${branch_name}"
git checkout "${branch_name}"
git branch ${{ needs.publish-release.outputs.version }}-changelog
git checkout ${{ needs.publish-release.outputs.version }}-changelog
printf '# Changelog\n\n%s\n' "${CHANGELOG}" > changelog-new.md
@@ -239,28 +227,24 @@ jobs:
mv changelog-new.md changelog.md
uv run \
--python "${PYTHON_VERSION}" \
--python ${{ steps.setup-python.outputs.python-version }} \
--dev \
prek run --files changelog.md || true
git config --global user.name "github-actions"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "Changelog ${VERSION} - GHA"
git push origin "${branch_name}"
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
git push origin ${{ needs.publish-release.outputs.version }}-changelog
- name: Create pull request
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
VERSION: ${{ needs.publish-release.outputs.version }}
with:
script: |
const { repo, owner } = context.repo;
const version = process.env.VERSION;
const head = `${version}-changelog`;
const result = await github.rest.pulls.create({
title: `Documentation: Add ${version} changelog`,
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
owner,
repo,
head,
head: '${{ needs.publish-release.outputs.version }}-changelog',
base: 'main',
body: 'This PR is auto-generated by CI.'
});

View File

@@ -18,7 +18,6 @@ jobs:
name: Cleanup Image Tags for ${{ matrix.primary-name }}
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
environment: registry-maintenance
strategy:
fail-fast: false
matrix:
@@ -45,7 +44,6 @@ jobs:
runs-on: ubuntu-24.04
needs:
- cleanup-images
environment: registry-maintenance
strategy:
fail-fast: false
matrix:

View File

@@ -14,7 +14,6 @@ jobs:
name: Crowdin Sync
if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04
environment: translation-sync
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

View File

@@ -7,7 +7,6 @@ jobs:
generate-translate-strings:
name: Generate Translation Strings
runs-on: ubuntu-latest
environment: translation-sync
permissions:
contents: write
steps:

32
.github/zizmor.yml vendored
View File

@@ -3,22 +3,54 @@ rules:
ignore:
# github.event_name is a GitHub-internal constant (push/pull_request/etc.),
# not attacker-controllable.
- ci-backend.yml:35
- ci-docker.yml:74
- ci-docs.yml:33
- ci-frontend.yml:32
# github.event.repository.default_branch refers to the target repo's setting,
# which only admins can change; not influenced by fork PR authors.
- ci-backend.yml:47
- ci-docs.yml:45
- ci-frontend.yml:44
# steps.setup-python.outputs.python-version is always a semver string (e.g. "3.12.0")
# produced by actions/setup-python from a hardcoded env var input.
- ci-backend.yml:106
- ci-backend.yml:121
- ci-backend.yml:169
- ci-docs.yml:88
- ci-docs.yml:92
- ci-release.yml:69
- ci-release.yml:78
- ci-release.yml:90
- ci-release.yml:96
- ci-release.yml:229
# needs.*.result is always one of: success/failure/cancelled/skipped.
- ci-backend.yml:211
- ci-backend.yml:212
- ci-backend.yml:216
- ci-docs.yml:131
- ci-docs.yml:132
- ci-frontend.yml:259
- ci-frontend.yml:260
- ci-frontend.yml:264
- ci-frontend.yml:269
- ci-frontend.yml:274
- ci-frontend.yml:279
# needs.changes.outputs.* is always "true" or "false".
- ci-backend.yml:206
- ci-docs.yml:126
- ci-frontend.yml:254
# steps.build.outputs.digest is always a SHA256 digest (sha256:[a-f0-9]{64}).
- ci-docker.yml:152
# needs.publish-release.outputs.version is the git tag name (e.g. v2.14.0);
# only maintainers can push tags upstream, and the tag pattern excludes
# shell metacharacters. Used in git commands and github-script JS, not eval.
- ci-release.yml:215
- ci-release.yml:216
- ci-release.yml:231
- ci-release.yml:237
- ci-release.yml:245
- ci-release.yml:248
dangerous-triggers:
ignore:
# Both workflows use pull_request_target solely to label/comment on fork PRs

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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