Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support limited permissions #200

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3e4fa97
fix: URL Macro registration
paulgessinger Jan 19, 2025
24f3116
feat: Constructor Helpers for user related models
paulgessinger Jan 19, 2025
0036688
refactor: DocumentStore gets explicit fetchUISettings
paulgessinger Jan 19, 2025
9e14e39
refactor: Repository.create(document:) gets return value
paulgessinger Jan 19, 2025
52b71e5
refactor: TransientRepository gets user / group management functions
paulgessinger Jan 19, 2025
8c9fb9d
refactor: DocumentStore suppresses PermissionsError in fetchAll
paulgessinger Jan 18, 2025
7d068b9
feat: DocumentList consults store permissions
paulgessinger Jan 8, 2025
602e727
docs: Prepare for permissions switchover
paulgessinger Sep 22, 2024
403682e
feat: DocumentStore checks perms before requesting
paulgessinger Jan 8, 2025
861b67f
refactor: DocumentListViewModel checks permissions error
paulgessinger Jan 8, 2025
796175d
refactor: Perms error output, localization
paulgessinger Jan 5, 2025
f26742d
refactor: Avoid lots of errors during loading (quiet failure)
paulgessinger Jan 5, 2025
ad76139
refactor: Reorganize localized resource implementaion
paulgessinger Jan 12, 2025
5ccca9e
refactor: Centralize no perms and no elements view
paulgessinger Jan 12, 2025
3e1b7a1
feat: Add subscript getter for permission set on user permissions
paulgessinger Jan 12, 2025
9c7a201
refactor: Begin reworking generic manage view with limited perms
paulgessinger Jan 12, 2025
7e54cd5
refactor: additional logging in checkPermissions
paulgessinger Jan 18, 2025
6338afb
test: Fix test compilation
paulgessinger Jan 18, 2025
1d4583f
feat: Start making management views permissions aware
paulgessinger Jan 18, 2025
e60722a
fixup! refactor: DocumentStore fetchAll logging and robustness
paulgessinger Jan 18, 2025
5d9f070
fix: Incorrect document no-perms screen
paulgessinger Jan 19, 2025
41cd5d7
feat: Progress on permissions aware permissions edit screen
paulgessinger Jan 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Common/Sources/Common/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
import Foundation

@freestanding(expression)
public macro URL<S: ExpressibleByStringLiteral>() -> URL = #externalMacro(module: "CommonMacros", type: "URLMacro")
public macro URL<S: ExpressibleByStringLiteral>(_: S) -> URL = #externalMacro(module: "CommonMacros", type: "URLMacro")
5 changes: 5 additions & 0 deletions DataModel/Sources/DataModel/PermissionsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public struct Permissions: Codable, Equatable, Hashable, Sendable {
self.view = view
self.change = change
}

public init(_ factory: (inout Permissions) -> Void) {
self.init()
factory(&self)
}
}

public protocol PermissionsModel {
Expand Down
20 changes: 20 additions & 0 deletions DataModel/Sources/DataModel/UserModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ public struct UserPermissions: Sendable {
return rule.test(operation)
}

public subscript(resource: Resource) -> PermissionSet {
rules[resource] ?? PermissionSet()
}

public mutating func set(_ operation: Operation, to value: Bool, for resource: Resource) {
if rules[resource] == nil {
rules[resource] = PermissionSet()
Expand Down Expand Up @@ -182,6 +186,22 @@ extension UserPermissions: Codable {
}
}

public extension UserPermissions {
private static func build(_ initial: Self, _ configure: (inout Self) -> Void) -> Self {
var initial = initial
configure(&initial)
return initial
}

static func empty(with configure: (inout Self) -> Void) -> Self {
build(.empty, configure)
}

static func full(with configure: (inout Self) -> Void) -> Self {
build(.full, configure)
}
}

extension UserPermissions.Operation: CustomStringConvertible {
public var description: String {
switch self {
Expand Down
36 changes: 36 additions & 0 deletions DataModel/Tests/DataModelTests/PermissionsTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,40 @@ struct PermissionsTest {
"Expected line to end with 'vacd': \(line)")
}
}

@Test func testSubscriptOperator() {
var permissions = UserPermissions(rules: [:])

// Test empty permissions
#expect(permissions[.document].description == "----")
#expect(permissions[.tag].description == "----")
#expect(!permissions[.document].test(.view))
#expect(!permissions[.document].test(.add))

// Test setting and reading permissions
permissions.set(.view, to: true, for: .document)
permissions.set(.add, to: true, for: .document)
#expect(permissions[.document].description == "va--")
#expect(permissions[.document].test(.view))
#expect(permissions[.document].test(.add))
#expect(!permissions[.document].test(.change))
#expect(!permissions[.document].test(.delete))

permissions.set(.change, to: true, for: .tag)
#expect(permissions[.tag].description == "--c-")
#expect(!permissions[.tag].test(.view))
#expect(!permissions[.tag].test(.add))
#expect(permissions[.tag].test(.change))
#expect(!permissions[.tag].test(.delete))

// Test full permissions
let fullPerms = UserPermissions.full
#expect(fullPerms[.document].description == "vacd")
#expect(fullPerms[.user].description == "vacd")
#expect(fullPerms[.tag].description == "vacd")
#expect(fullPerms[.document].test(.view))
#expect(fullPerms[.document].test(.add))
#expect(fullPerms[.document].test(.change))
#expect(fullPerms[.document].test(.delete))
}
}
7 changes: 7 additions & 0 deletions docs/common_issues/forbidden.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Error "No access: XXX"

!!! note

In versions up to and including `v1.5.0`, the app required at least
read-access to most of the resources available in Paperless-ngx. Starting from
`v1.6.0`, only a minimal set of permissions is required to access the app. See
[Minimum permissions](minimum-permissions.md) for more information.

This error occurs when the user does not have the necessary permissions to access the requested resource. This is usually the result of a faulty configuation in Paperless-ngx itself.

To resolve this issue, please navigate to the *Users & Groups* page in the *Administration* section, and make sure that the *View* checkbox is ticked for the user in question.
Expand Down
Binary file added docs/common_issues/minimum-permissions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions docs/common_issues/permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Minimum permissions

Permissions in Paperless-ngx control what resources a logged in user has access to.
Generally, Swift Paperless will respect the given permissions and not try to load resources
that the user will not be able to access.

For the app to function correctly, it needs to be able to query information about the logged in user itself,
which includes the level of permissions the user has. Without this level of access, the functionality of the app cannot be provided.

Please ensure that the user you're trying to log in with has at leas the *UI Settings* permissions set in the web interface under *Users & Groups*:

![Screenshot of the permissions interface](minimum-permissions.png)
6 changes: 6 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,15 @@ plugins:
- literate-nav
- redirects:
redirect_maps:
# old
"common-issues/forbidden.md": "common_issues/forbidden.md"
"common-issues/invalid-certificate.md": "common_issues/invalid-certificate.md"

# new
# "common_issues/forbidden.md": "common_issues/permissions.md"
# "common-issues/forbidden.md": "common_issues/permissions.md"
"common_issues/invalid-certificate.md": "common_issues/certificates.md"

"common-issues/local-network-denied.md": "common_issues/local-network-denied.md"

markdown_extensions:
Expand Down
10 changes: 9 additions & 1 deletion swift-paperless.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7A2883A82D3C099700B88C70 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Localization/LocalizedResource.swift,
"Localization/UserPermissionsResource+localizedName.swift",
);
target = 7A5482BB299AD56E00D5061E /* swift-paperlessTests */;
};
7A40DD982D10BEAA0033DDE5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Expand Down Expand Up @@ -211,7 +219,7 @@
7A40DD832D10BEA10033DDE5 /* swift-paperlessUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "swift-paperlessUITests"; sourceTree = "<group>"; };
7A40DD912D10BEAA0033DDE5 /* ShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7A40DD982D10BEAA0033DDE5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = ShareExtension; sourceTree = "<group>"; };
7AC40DFC2D134EDE003F68B1 /* Localization */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7AC40FA32D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 7AC40FAD2D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Localization; sourceTree = "<group>"; };
7AC40E272D134EDE003F68B1 /* Model */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Model; sourceTree = "<group>"; };
7AC40E272D134EDE003F68B1 /* Model */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7A2883A82D3C099700B88C70 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Model; sourceTree = "<group>"; };
7AC40E672D134EDE003F68B1 /* Utilities */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7AC40FA52D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 7AC40FA62D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 7AC40FA72D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Utilities; sourceTree = "<group>"; };
7AC40EDE2D134EDE003F68B1 /* Views */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7AC40FA82D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 7AC40FA92D134EDE003F68B1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Views; sourceTree = "<group>"; };
7AC40F622D134EDE003F68B1 /* Preview Content */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Preview Content"; sourceTree = "<group>"; };
Expand Down
71 changes: 56 additions & 15 deletions swift-paperless/Document/DocumentStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ final class DocumentStore: ObservableObject, Sendable {
}

func updateDocument(_ document: Document) async throws -> Document {
try checkPermission(.change, for: .document)
eventPublisher.send(.changed(document: document))

var document = document
Expand All @@ -150,12 +151,14 @@ final class DocumentStore: ObservableObject, Sendable {
}

func deleteDocument(_ document: Document) async throws {
try checkPermission(.delete, for: .document)
try await repository.delete(document: document)
documents.removeValue(forKey: document.id)
eventPublisher.send(.deleted(document: document))
}

func deleteNote(from document: Document, id: UInt) async throws {
try checkPermission(.change, for: .document)
eventPublisher.send(.changed(document: document))
let notes = try await repository.deleteNote(id: id, documentId: document.id)

Expand All @@ -167,6 +170,7 @@ final class DocumentStore: ObservableObject, Sendable {
}

func addNote(to document: Document, note: ProtoDocument.Note) async throws {
try checkPermission(.change, for: .document)
eventPublisher.send(.changed(document: document))

let notes = try await repository.createNote(documentId: document.id, note: note)
Expand All @@ -179,6 +183,9 @@ final class DocumentStore: ObservableObject, Sendable {
}

func fetchTasks() async {
guard (try? checkPermission(.view, for: .paperlessTask)) != nil else {
return
}
guard let tasks = try? await repository.tasks() else {
return
}
Expand All @@ -191,31 +198,41 @@ final class DocumentStore: ObservableObject, Sendable {
}

func fetchAllCorrespondents() async throws {
// @TODO: For the `fetchAll` calls: centralize this to that method.
// Use a property on the resource to map to the permissions resource.
// Also: clear the associated resource if there's a permissions error
try checkPermission(.view, for: .correspondent)
try await fetchAll(elements: repository.correspondents(),
collection: \.correspondents)
}

func fetchAllDocumentTypes() async throws {
try checkPermission(.view, for: .documentType)
try await fetchAll(elements: repository.documentTypes(),
collection: \.documentTypes)
}

func fetchAllTags() async throws {
try checkPermission(.view, for: .tag)
try await fetchAll(elements: repository.tags(),
collection: \.tags)
}

func fetchAllSavedViews() async throws {
try checkPermission(.view, for: .savedView)
try await fetchAll(elements: repository.savedViews(),
collection: \.savedViews)
}

func fetchAllStoragePaths() async throws {
try checkPermission(.view, for: .storagePath)
try await fetchAll(elements: repository.storagePaths(),
collection: \.storagePaths)
}

func fetchCurrentUser() async throws {
// this should basically always be the case but let's be safe
try checkPermission(.view, for: .uiSettings)
if currentUser != nil {
// We don't expect this to change
return
Expand All @@ -232,22 +249,18 @@ final class DocumentStore: ObservableObject, Sendable {
}

func fetchAllUsers() async throws {
try checkPermission(.view, for: .user)
try await fetchAll(elements: repository.users(),
collection: \.users)
}

func fetchAllGroups() async throws {
try checkPermission(.view, for: .group)
try await fetchAll(elements: repository.groups(),
collection: \.groups)
}

func fetchAll() async throws {
// @TODO: This gets called concurrently during startup, maybe debounce
Logger.shared.notice("Fetch all store request")
await fetchAllSemaphore.wait()
defer { fetchAllSemaphore.signal() }
Logger.shared.notice("Fetch all store")

func fetchUISettings() async throws {
do {
// This can fail if we don't have the required permissions to even access UI settings
// Older versions of the backend return an ok response here even if the perms aren't valid
Expand All @@ -260,7 +273,19 @@ final class DocumentStore: ObservableObject, Sendable {
Logger.shared.error("Assuming full permissions to proceed")
permissions = UserPermissions.full
settings = UISettingsSettings()
throw error
}
}

func fetchAll() async throws {
// @TODO: This gets called concurrently during startup, maybe debounce
Logger.shared.notice("Fetch all store request")
await fetchAllSemaphore.wait()
defer { fetchAllSemaphore.signal() }
Logger.shared.notice("Fetch all store")
print("start fetch all")

try? await fetchUISettings()

let permissions = permissions
Logger.shared.info("Permissions returned from backend:\n\(permissions.matrix)")
Expand All @@ -281,11 +306,15 @@ final class DocumentStore: ObservableObject, Sendable {
while !group.isEmpty {
do {
try await group.next()
} catch is PermissionsError {
Logger.shared.debug("Fetch all task returned permissions error, suppressing")
continue
} catch let error where error.isCancellationError {
Logger.shared.debug("Fetch all task caught cancellation, suppressing")
continue
} catch {
Logger.shared.error("Fetch all task caught error: \(error)")
// @TODO: This cancels the other tasks, maybe we want to continue
throw error
}
}
Expand Down Expand Up @@ -323,25 +352,30 @@ final class DocumentStore: ObservableObject, Sendable {
}

func getCorrespondent(id: UInt) async throws -> (Bool, Correspondent)? {
try await getSingleCached(get: { try await repository.correspondent(id: $0) }, id: id,
cache: \.correspondents)
try checkPermission(.view, for: .correspondent)
return try await getSingleCached(get: { try await repository.correspondent(id: $0) }, id: id,
cache: \.correspondents)
}

func getDocumentType(id: UInt) async throws -> (Bool, DocumentType)? {
try await getSingleCached(get: { try await repository.documentType(id: $0) }, id: id,
cache: \.documentTypes)
try checkPermission(.view, for: .documentType)
return try await getSingleCached(get: { try await repository.documentType(id: $0) }, id: id,
cache: \.documentTypes)
}

func document(id: UInt) async throws -> Document? {
try await repository.document(id: id)
try checkPermission(.view, for: .document)
return try await repository.document(id: id)
}

func getTag(id: UInt) async throws -> (Bool, Tag)? {
try await getSingleCached(get: { try await repository.tag(id: $0) }, id: id,
cache: \.tags)
try checkPermission(.view, for: .tag)
return try await getSingleCached(get: { try await repository.tag(id: $0) }, id: id,
cache: \.tags)
}

func getTags(_ ids: [UInt]) async throws -> (Bool, [Tag]) {
try checkPermission(.view, for: .tag)
var tags: [Tag] = []
var allCached = true
for id in ids {
Expand Down Expand Up @@ -438,7 +472,7 @@ final class DocumentStore: ObservableObject, Sendable {
}

func create(document: ProtoDocument, file: URL) async throws {
try await repository.create(document: document, file: file)
_ = try await repository.create(document: document, file: file)
startTaskPolling()
}

Expand Down Expand Up @@ -469,4 +503,11 @@ final class DocumentStore: ObservableObject, Sendable {
store: \.storagePaths,
method: repository.delete(storagePath:))
}

private func checkPermission(_ operation: UserPermissions.Operation, for resource: UserPermissions.Resource) throws {
if !permissions.test(operation, for: resource) {
Logger.api.debug("No permissions for \(operation.description) on \(resource.rawValue)")
throw PermissionsError(resource: resource, operation: operation)
}
}
}
Loading
Loading