From b3b3b33af4d2befefe40997f4e3bfdfa43c0bfc5 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 20 Feb 2026 12:29:03 +0100 Subject: [PATCH 1/6] fix naming conventions --- .../FeatherAccessControl/{ACL => }/ACL.swift | 41 ++--- .../FeatherAccessControl/AccessControl.swift | 30 ++-- .../AccessControlError.swift | 8 - ...nterface.swift => AccessControlList.swift} | 106 ++++-------- Sources/FeatherAccessControl/Permission.swift | 151 ++++++++++-------- ...CLSet.swift => PermissionCollection.swift} | 13 +- .../FeatherAccessControlTestSuite.swift | 23 +-- .../PermissionTestSuite.swift | 47 ++++-- 8 files changed, 184 insertions(+), 235 deletions(-) rename Sources/FeatherAccessControl/{ACL => }/ACL.swift (57%) rename Sources/FeatherAccessControl/{ACL/ACLInterface.swift => AccessControlList.swift} (54%) rename Sources/FeatherAccessControl/{ACL/ACLSet.swift => PermissionCollection.swift} (57%) diff --git a/Sources/FeatherAccessControl/ACL/ACL.swift b/Sources/FeatherAccessControl/ACL.swift similarity index 57% rename from Sources/FeatherAccessControl/ACL/ACL.swift rename to Sources/FeatherAccessControl/ACL.swift index 0c2dfad..26d403e 100644 --- a/Sources/FeatherAccessControl/ACL/ACL.swift +++ b/Sources/FeatherAccessControl/ACL.swift @@ -4,58 +4,51 @@ // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 06/03/2024. -// - /// Default ACL implementation backed by static role and permission key lists. -public struct ACL: ACLInterface { +public struct ACL: AccessControlList { /// Identifier of the account the ACL belongs to. public let accountId: String /// Role keys granted to the account. - public let roleKeys: [String] + public let roles: [String] /// Permission keys granted to the account. - public let permissionKeys: [String] + public let permissions: [String] /// Creates a new ACL value. /// /// - Parameters: /// - accountId: Identifier of the account. - /// - roleKeys: Role keys available to the account. - /// - permissionKeys: Permission keys available to the account. + /// - roles: Role keys available to the account. + /// - permissions: Permission keys available to the account. public init( accountId: String, - roleKeys: [String] = [], - permissionKeys: [String] = [] + roles: [String] = [], + permissions: [String] = [] ) { self.accountId = accountId - self.roleKeys = roleKeys - self.permissionKeys = permissionKeys + self.roles = roles + self.permissions = permissions } /// Checks whether the ACL contains the given role key. /// - /// - Parameter roleKey: Role key to test. + /// - Parameter role: Role key to test. /// - Returns: `true` when the role key is present, otherwise `false`. /// - Throws: This method currently does not throw. public func has( - roleKey: String - ) async throws -> Bool { - roleKeys.contains(roleKey) + role: String + ) async throws(AccessControlError) -> Bool { + roles.contains(role) } /// Checks whether the ACL contains the given permission key. /// - /// - Parameter permissionKey: Permission key to test. + /// - Parameter permission: Permission key to test. /// - Returns: `true` when the permission key is present, otherwise `false`. /// - Throws: This method currently does not throw. public func has( - permissionKey: String - ) async throws -> Bool { - permissionKeys.contains(permissionKey) + permission: String + ) async throws(AccessControlError) -> Bool { + permissions.contains(permission) } } diff --git a/Sources/FeatherAccessControl/AccessControl.swift b/Sources/FeatherAccessControl/AccessControl.swift index 77d3c66..11a2fdd 100644 --- a/Sources/FeatherAccessControl/AccessControl.swift +++ b/Sources/FeatherAccessControl/AccessControl.swift @@ -4,18 +4,11 @@ // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 05/02/2024. -// - /// Task-local access control context utilities. public enum AccessControl: Sendable { @TaskLocal - private static var rawValue: ACLInterface? + private static var rawValue: AccessControlList? /// Runs a block with the given ACL value set for the current task context. /// @@ -24,7 +17,7 @@ public enum AccessControl: Sendable { /// - block: Async block executed with the provided ACL context. /// - Returns: The result produced by `block`. /// - Throws: Rethrows any error thrown by `block`. - public static func set( + public static func set( _ acl: T?, _ block: (() async throws -> R) ) async throws -> R { @@ -40,7 +33,7 @@ public enum AccessControl: Sendable { /// - block: Async block executed without an ACL context. /// - Returns: The result produced by `block`. /// - Throws: Rethrows any error thrown by `block`. - public static func unset( + public static func unset( _ type: T.Type, _ block: (() async throws -> R) ) async throws -> R { @@ -54,11 +47,10 @@ public enum AccessControl: Sendable { /// - Parameter type: ACL type to retrieve from the task-local context. /// - Returns: The typed ACL value, or `nil` if unavailable. /// - Throws: This method currently does not throw. - public static func get( + public static func get( _ type: T.Type ) async throws -> T? { - _ = type - return rawValue as? T + rawValue as? T } /// Returns the current task-local ACL value or throws if it is missing. @@ -66,12 +58,11 @@ public enum AccessControl: Sendable { /// - Parameter type: ACL type to retrieve from the task-local context. /// - Returns: The typed ACL value. /// - Throws: ``AccessControlError/unauthorized`` if no matching ACL is set. - public static func require( + public static func require( _ type: T.Type - ) async throws -> T { - _ = type + ) async throws(AccessControlError) -> T { guard let value = rawValue as? T else { - throw AccessControlError.unauthorized + throw .unauthorized } return value } @@ -81,10 +72,9 @@ public enum AccessControl: Sendable { /// - Parameter type: ACL type to check for. /// - Returns: `true` when a matching ACL is available, otherwise `false`. /// - Throws: This method currently does not throw. - public static func exists( + public static func exists( _ type: T.Type ) async throws -> Bool { - _ = type - return rawValue as? T != nil + try await get(T.self) != nil } } diff --git a/Sources/FeatherAccessControl/AccessControlError.swift b/Sources/FeatherAccessControl/AccessControlError.swift index 2e4196d..2f50b3d 100644 --- a/Sources/FeatherAccessControl/AccessControlError.swift +++ b/Sources/FeatherAccessControl/AccessControlError.swift @@ -4,13 +4,6 @@ // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 27/02/2024. -// - /// Errors related to access control context and authorization checks. public enum AccessControlError: Error { @@ -23,7 +16,6 @@ public enum AccessControlError: Error { case permission /// A role check failed. case role - // case access } /// Key that failed authorization. diff --git a/Sources/FeatherAccessControl/ACL/ACLInterface.swift b/Sources/FeatherAccessControl/AccessControlList.swift similarity index 54% rename from Sources/FeatherAccessControl/ACL/ACLInterface.swift rename to Sources/FeatherAccessControl/AccessControlList.swift index dd70307..93a5847 100644 --- a/Sources/FeatherAccessControl/ACL/ACLInterface.swift +++ b/Sources/FeatherAccessControl/AccessControlList.swift @@ -1,36 +1,29 @@ // -// ACLInterface.swift +// AccessControlList.swift // feather-access-control // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 06/03/2024. -// - /// Interface describing role and permission checks for an ACL implementation. -public protocol ACLInterface: Sendable { +public protocol AccessControlList: Sendable { /// Checks whether the ACL contains the given role. /// - /// - Parameter roleKey: Role key to test. + /// - Parameter role: Role key to test. /// - Returns: `true` when the role is present, otherwise `false`. /// - Throws: Any error produced by the ACL backend. func has( - roleKey: String - ) async throws -> Bool + role: String + ) async throws(AccessControlError) -> Bool /// Checks whether the ACL contains the given permission key. /// - /// - Parameter permissionKey: Permission key to test. + /// - Parameter permission: Permission key to test. /// - Returns: `true` when the permission is present, otherwise `false`. /// - Throws: Any error produced by the ACL backend. func has( - permissionKey: String - ) async throws -> Bool + permission: String + ) async throws(AccessControlError) -> Bool /// Checks whether the ACL contains the given permission value. /// @@ -39,28 +32,23 @@ public protocol ACLInterface: Sendable { /// - Throws: Any error produced by the ACL backend. func has( permission: Permission - ) async throws -> Bool - - // func hasAccess( - // _ key: String, - // userInfo: [String: Any] - // ) async throws -> Bool + ) async throws(AccessControlError) -> Bool /// Requires the ACL to contain the given role. /// - /// - Parameter roleKey: Required role key. + /// - Parameter role: Required role key. /// - Throws: ``AccessControlError/forbidden(_:)`` when the role is missing. func require( - roleKey: String - ) async throws + role: String + ) async throws(AccessControlError) /// Requires the ACL to contain the given permission key. /// - /// - Parameter permissionKey: Required permission key. + /// - Parameter permission: Required permission key. /// - Throws: ``AccessControlError/forbidden(_:)`` when the permission is missing. func require( - permissionKey: String - ) async throws + permission: String + ) async throws(AccessControlError) /// Requires the ACL to contain the given permission value. /// @@ -68,45 +56,33 @@ public protocol ACLInterface: Sendable { /// - Throws: ``AccessControlError/forbidden(_:)`` when the permission is missing. func require( permission: Permission - ) async throws - - // func requireAccess( - // _ key: String, - // userInfo: [String: Any] - // ) async throws + ) async throws(AccessControlError) } -extension ACLInterface { - - // public func hasAccess( - // _ key: String, - // userInfo: [String: Any] - // ) async throws -> Bool { - // try await hasPermission(key) - // } +extension AccessControlList { /// Checks whether the ACL contains the given permission value. /// /// - Parameter permission: Permission value to test. /// - Returns: `true` when the permission is present, otherwise `false`. - /// - Throws: Any error produced by `has(permissionKey:)`. + /// - Throws: Any error produced by `has(permission:)`. public func has( permission: Permission - ) async throws -> Bool { - try await has(permissionKey: permission.key) + ) async throws(AccessControlError) -> Bool { + try await has(permission: permission.rawValue) } /// Requires the ACL to contain the given role. /// - /// - Parameter roleKey: Required role key. + /// - Parameter role: Required role key. /// - Throws: ``AccessControlError/forbidden(_:)`` when the role is missing. public func require( - roleKey: String - ) async throws { - guard try await has(roleKey: roleKey) else { - throw AccessControlError.forbidden( + role: String + ) async throws(AccessControlError) { + guard try await has(role: role) else { + throw .forbidden( .init( - key: roleKey, + key: role, kind: .role ) ) @@ -115,15 +91,15 @@ extension ACLInterface { /// Requires the ACL to contain the given permission key. /// - /// - Parameter permissionKey: Required permission key. + /// - Parameter permission: Required permission key. /// - Throws: ``AccessControlError/forbidden(_:)`` when the permission is missing. public func require( - permissionKey: String - ) async throws { - guard try await has(permissionKey: permissionKey) else { - throw AccessControlError.forbidden( + permission: String + ) async throws(AccessControlError) { + guard try await has(permission: permission) else { + throw .forbidden( .init( - key: permissionKey, + key: permission, kind: .permission ) ) @@ -136,21 +112,7 @@ extension ACLInterface { /// - Throws: ``AccessControlError/forbidden(_:)`` when the permission is missing. public func require( permission: Permission - ) async throws { - try await require(permissionKey: permission.key) + ) async throws(AccessControlError) { + try await require(permission: permission.rawValue) } - - // public func requireAccess( - // _ key: String, - // userInfo: [String: Any] - // ) async throws { - // guard try await hasAccess(key, userInfo: userInfo) else { - // throw AccessControlError.forbidden( - // .init( - // key: key, - // kind: .access - // ) - // ) - // } - // } } diff --git a/Sources/FeatherAccessControl/Permission.swift b/Sources/FeatherAccessControl/Permission.swift index e59dbc5..305c277 100644 --- a/Sources/FeatherAccessControl/Permission.swift +++ b/Sources/FeatherAccessControl/Permission.swift @@ -4,73 +4,16 @@ // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 21/03/2024. -// - /// Generic permission object. public struct Permission: Equatable, Hashable, Codable, Sendable { - private static let separator = "." - - /// Namespace of the permission, usually the module name. - public let namespace: String - /// Context of the permission, usually a model name. - public let context: String - /// Action for the given namespace & context. - public let action: Action - - /// Creates a new permission from namespace, context, and action. - /// - /// - Parameters: - /// - namespace: Namespace of the permission, typically a module name. - /// - context: Context of the permission, typically a model name. - /// - action: Action permitted for the given namespace and context. - public init( - namespace: String, - context: String, - action: Action - ) { - self.namespace = namespace - self.context = context - self.action = action - } - - /// Creates a permission from a key with three components. - /// - /// The expected format is `namespace.context.action`. - /// - /// - Parameter key: Permission key to parse. - public init( - _ key: String - ) { - let parts = key.split(separator: Self.separator).map(String.init) - guard parts.count == 3 else { - fatalError("Invalid permission key") - } - self.namespace = parts[0] - self.context = parts[1] - self.action = .init(parts[2]) - } -} - -extension Permission { - - /// Namespace, context, and action key components. - public var components: [String] { [namespace, context, action.key] } - /// String identifier of the permission in `namespace.context.action` format. - public var key: String { components.joined(separator: Self.separator) } - /// Permission key with an `.access` suffix. - public var accessKey: String { key + Self.separator + "access" } -} - -extension Permission { + public static let separator = "." /// Generic action for permissions. - public enum Action: Equatable, Codable, Sendable, Hashable { + public enum Action: RawRepresentable, Equatable, Codable, Sendable, + Hashable, ExpressibleByStringLiteral + { + /// Action for list objects. case list /// Action for object details. @@ -87,19 +30,21 @@ extension Permission { /// Creates an action from a raw key. /// /// - Parameter key: Action key string. - public init(_ key: String) { - switch key { + public init( + rawValue: String + ) { + switch rawValue { case "list": self = .list case "detail": self = .detail case "create": self = .create case "update": self = .update case "delete": self = .delete - default: self = .custom(key) + default: self = .custom(rawValue) } } /// Raw key representation of the action. - public var key: String { + public var rawValue: String { switch self { case .list: return "list" case .detail: return "detail" @@ -110,16 +55,82 @@ extension Permission { } } + public init( + stringLiteral value: StringLiteralType + ) { + self = .init(rawValue: value) + } + /// Decodes an action from its raw string representation. - public init(from decoder: Decoder) throws { + public init( + from decoder: Decoder + ) throws { let container = try decoder.singleValueContainer() - self = .init(try container.decode(String.self)) + self = .init(rawValue: try container.decode(String.self)) } /// Encodes an action as its raw string representation. - public func encode(to encoder: Encoder) throws { + public func encode( + to encoder: Encoder + ) throws { var container = encoder.singleValueContainer() - try container.encode(key) + try container.encode(rawValue) } } + + /// Namespace of the permission, usually the module name. + public let namespace: String + /// Context of the permission, usually a model name. + public let context: String + /// Action for the given namespace & context. + public let action: Action + /// The separator used to separate components. + public let separator: String + + /// Creates a new permission from namespace, context, and action. + /// + /// - Parameters: + /// - namespace: Namespace of the permission, typically a module name. + /// - context: Context of the permission, typically a model name. + /// - action: Action permitted for the given namespace and context. + public init( + namespace: String, + context: String, + action: Action, + separator: String = Self.separator + ) { + self.namespace = namespace + self.context = context + self.action = action + self.separator = separator + } + + /// Creates a permission from a key with three components. + /// + /// The expected format is `namespace.context.action`. + /// + /// - Parameter rawValue: Permission key to parse. + public init?( + rawValue: String, + separator: String = Self.separator + ) { + let parts = rawValue.split(separator: separator).map(String.init) + guard parts.count == 3 else { + return nil + } + self.namespace = parts[0] + self.context = parts[1] + self.action = .init(rawValue: parts[2]) + self.separator = separator + } + + /// Namespace, context, and action key components. + public var components: [String] { + [namespace, context, action.rawValue] + } + + /// Raw value using the namespace, context and action joined with the separator. + public var rawValue: String { + components.joined(separator: separator) + } } diff --git a/Sources/FeatherAccessControl/ACL/ACLSet.swift b/Sources/FeatherAccessControl/PermissionCollection.swift similarity index 57% rename from Sources/FeatherAccessControl/ACL/ACLSet.swift rename to Sources/FeatherAccessControl/PermissionCollection.swift index f1c364d..f5052c5 100644 --- a/Sources/FeatherAccessControl/ACL/ACLSet.swift +++ b/Sources/FeatherAccessControl/PermissionCollection.swift @@ -1,18 +1,11 @@ // -// ACLSet.swift +// PermissionCollection.swift // feather-access-control // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 21/03/2024. -// - /// Describes a static collection of permissions for a domain or module. -public protocol ACLSet { +public protocol PermissionCollection { /// Complete list of permissions defined by the set. - static var all: [Permission] { get } + static var permissions: [Permission] { get } } diff --git a/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift b/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift index 2a6661b..6e45f1e 100644 --- a/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift +++ b/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift @@ -4,13 +4,6 @@ // // Created by Binary Birds on 2026. 02. 17. -// -// File.swift -// -// -// Created by Tibor Bodecs on 04/02/2024. -// - import Testing @testable import FeatherAccessControl @@ -31,8 +24,8 @@ struct FeatherAccessControlTestSuite { func testSetACL() async throws { let acl = ACL( accountId: "test-id", - roleKeys: ["test-role"], - permissionKeys: ["test-permission"] + roles: ["test-role"], + permissions: ["test-permission"] ) try await AccessControl.set(acl) { @@ -56,14 +49,14 @@ struct FeatherAccessControlTestSuite { func testRequireACL() async throws { let acl = ACL( accountId: "test-id", - roleKeys: ["test-role"], - permissionKeys: ["test-permission"] + roles: ["test-role"], + permissions: ["test-permission"] ) try await AccessControl.set(acl) { let acl = try await AccessControl.require(ACL.self) - try await acl.require(roleKey: "test-role") - try await acl.require(permissionKey: "test-permission") + try await acl.require(role: "test-role") + try await acl.require(permission: "test-permission") } } @@ -88,7 +81,7 @@ struct FeatherAccessControlTestSuite { do { try await AccessControl.set(acl) { let acl = try await AccessControl.require(ACL.self) - try await acl.require(roleKey: "test-role") + try await acl.require(role: "test-role") } Issue.record("Expected forbidden role error.") } @@ -108,7 +101,7 @@ struct FeatherAccessControlTestSuite { do { try await AccessControl.set(acl) { let acl = try await AccessControl.require(ACL.self) - try await acl.require(permissionKey: "test-permission") + try await acl.require(permission: "test-permission") } Issue.record("Expected forbidden permission error.") } diff --git a/Tests/FeatherAccessControlTests/PermissionTestSuite.swift b/Tests/FeatherAccessControlTests/PermissionTestSuite.swift index c0d06df..42bbb66 100644 --- a/Tests/FeatherAccessControlTests/PermissionTestSuite.swift +++ b/Tests/FeatherAccessControlTests/PermissionTestSuite.swift @@ -4,13 +4,6 @@ // // Created by Binary Birds on 2026. 02. 17. -// -// PermissionTests.swift -// -// -// Created by Codex on 17/02/2026. -// - import Foundation import Testing @@ -24,30 +17,52 @@ struct PermissionTestSuite { let permission = Permission( namespace: "app", context: "article", - action: .create + action: "create", + separator: ":" ) #expect(permission.components == ["app", "article", "create"]) - #expect(permission.key == "app.article.create") - #expect(permission.accessKey == "app.article.create.access") + #expect(permission.action == .create) + #expect(permission.rawValue == "app:article:create") + } + + @Test + func testPermissionFromPartsBuildsDerivedKeysCustomAction() { + let permission = Permission( + namespace: "app", + context: "article", + action: "exists", + separator: "_" + ) + + #expect(permission.components == ["app", "article", "exists"]) + #expect(permission.action == .custom("exists")) + #expect(permission.rawValue == "app_article_exists") } @Test func testPermissionFromKeyParsesKnownAction() { - let permission = Permission("app.article.update") + guard let permission = Permission(rawValue: "app.article.update") else { + Issue.record("Permission should exist.") + return + } #expect(permission.namespace == "app") #expect(permission.context == "article") #expect(permission.action == .update) - #expect(permission.key == "app.article.update") + #expect(permission.rawValue == "app.article.update") } @Test func testPermissionFromKeyParsesCustomAction() { - let permission = Permission("app.article.publish") + guard let permission = Permission(rawValue: "app.article.publish") + else { + Issue.record("Permission should exist.") + return + } #expect(permission.action == .custom("publish")) - #expect(permission.key == "app.article.publish") + #expect(permission.rawValue == "app.article.publish") } @Test @@ -80,7 +95,7 @@ struct PermissionTestSuite { context: "article", action: .delete ) - let acl = ACL(accountId: "test-id", permissionKeys: [permission.key]) + let acl = ACL(accountId: "test-id", permissions: [permission.rawValue]) let hasPermission = try await acl.has(permission: permission) #expect(hasPermission) @@ -101,7 +116,7 @@ struct PermissionTestSuite { } catch AccessControlError.forbidden(let state) { #expect(state.kind == .permission) - #expect(state.key == permission.key) + #expect(state.key == permission.rawValue) } catch { Issue.record("Unexpected error type.") From 920be69872124386951f749cb3ddc889f28294ee Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 20 Feb 2026 12:35:42 +0100 Subject: [PATCH 2/6] format & docc fixes --- Sources/FeatherAccessControl/Permission.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/FeatherAccessControl/Permission.swift b/Sources/FeatherAccessControl/Permission.swift index 305c277..8ec6840 100644 --- a/Sources/FeatherAccessControl/Permission.swift +++ b/Sources/FeatherAccessControl/Permission.swift @@ -7,6 +7,7 @@ /// Generic permission object. public struct Permission: Equatable, Hashable, Codable, Sendable { + /// Default separator used between permission components. public static let separator = "." /// Generic action for permissions. @@ -29,7 +30,7 @@ public struct Permission: Equatable, Hashable, Codable, Sendable { /// Creates an action from a raw key. /// - /// - Parameter key: Action key string. + /// - Parameter rawValue: Action key string. public init( rawValue: String ) { @@ -55,6 +56,9 @@ public struct Permission: Equatable, Hashable, Codable, Sendable { } } + /// Creates an action from a string literal. + /// + /// - Parameter value: Action key string. public init( stringLiteral value: StringLiteralType ) { @@ -93,6 +97,7 @@ public struct Permission: Equatable, Hashable, Codable, Sendable { /// - namespace: Namespace of the permission, typically a module name. /// - context: Context of the permission, typically a model name. /// - action: Action permitted for the given namespace and context. + /// - separator: The separator to use when converting to rawValue. public init( namespace: String, context: String, @@ -109,7 +114,9 @@ public struct Permission: Equatable, Hashable, Codable, Sendable { /// /// The expected format is `namespace.context.action`. /// - /// - Parameter rawValue: Permission key to parse. + /// - Parameters: + /// - rawValue: Permission key to parse. + /// - separator: Separator used to split the permission key. public init?( rawValue: String, separator: String = Self.separator From fdf241ac6503f24fb3762fa9d043ab3bdf7116a4 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 20 Feb 2026 12:36:38 +0100 Subject: [PATCH 3/6] pin actions version --- .github/workflows/testing.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index fe7fad0..5e8f5da 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,7 +13,7 @@ jobs: swiftlang_checks: name: Swiftlang Checks - uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.7 with: license_header_check_project_name: "project" format_check_enabled : true @@ -34,4 +34,4 @@ jobs: run_tests_with_cache_enabled : true headers_check_enabled : false docc_warnings_check_enabled : true - run_tests_swift_versions: '["6.1","6.2"]' \ No newline at end of file + run_tests_swift_versions: '["6.1","6.2"]' From c91e10c53e99b566faa7b10dbb1914d1b01abbbb Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 20 Feb 2026 12:48:26 +0100 Subject: [PATCH 4/6] update readme --- README.md | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e68c8d0..6f0b944 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,12 @@ A universal access control library for server-side Swift projects. ## Features -- TODO +- Async `AccessControlList` protocol for role/permission checks +- Task-local access control context via `AccessControl.set/get/require/unset` +- Default `ACL` implementation with static roles and permission keys +- Structured errors for unauthorized and forbidden access states +- Strongly typed `Permission` model with namespace/context/action structure +- Built-in CRUD action keys (`list`, `detail`, `create`, `update`, `delete`) plus custom actions ## Requirements @@ -49,7 +54,38 @@ Then add `FeatherAccessControl` to your target dependencies: https://feather-framework.github.io/feather-access-control/ ) -TODO +Define account identifier, roles and permissions using an ACL object: + +```swift +import FeatherAccessControl + +let acl = ACL( + accountId: "user-account-123", + roles: [ + "editor" + ], + permissions: [ + "article:write", + "article:read", + ] +) +``` + +Set the ACL for a request/task and require access: + +```swift +try await AccessControl.set(acl) { + let acl = try await AccessControl.require(ACL.self) + + try await acl.require(role: "editor") + try await acl.require(permission: "article:write") +} +``` + +Error behavior: + +- `AccessControl.require(...)` throws `AccessControlError.unauthorized` when no ACL is set. +- `acl.require(role:)` / `acl.require(permission:)` throws `AccessControlError.forbidden` when access is missing. > [!WARNING] > This repository is a work in progress, things can break until it reaches v1.0.0. From 882aefce6d5fd7c747a158e98261114b7d0d2a1a Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 20 Feb 2026 12:54:47 +0100 Subject: [PATCH 5/6] remove opinionated permission logic --- README.md | 5 +- .../AccessControlList.swift | 38 ----- Sources/FeatherAccessControl/Permission.swift | 143 ------------------ .../PermissionCollection.swift | 11 -- .../PermissionTestSuite.swift | 125 --------------- 5 files changed, 2 insertions(+), 320 deletions(-) delete mode 100644 Sources/FeatherAccessControl/Permission.swift delete mode 100644 Sources/FeatherAccessControl/PermissionCollection.swift delete mode 100644 Tests/FeatherAccessControlTests/PermissionTestSuite.swift diff --git a/README.md b/README.md index 6f0b944..48d2f80 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ A universal access control library for server-side Swift projects. - Async `AccessControlList` protocol for role/permission checks - Task-local access control context via `AccessControl.set/get/require/unset` -- Default `ACL` implementation with static roles and permission keys +- Default `ACL` implementation with roles and permission keys - Structured errors for unauthorized and forbidden access states -- Strongly typed `Permission` model with namespace/context/action structure -- Built-in CRUD action keys (`list`, `detail`, `create`, `update`, `delete`) plus custom actions + ## Requirements diff --git a/Sources/FeatherAccessControl/AccessControlList.swift b/Sources/FeatherAccessControl/AccessControlList.swift index 93a5847..49fd9c7 100644 --- a/Sources/FeatherAccessControl/AccessControlList.swift +++ b/Sources/FeatherAccessControl/AccessControlList.swift @@ -25,15 +25,6 @@ public protocol AccessControlList: Sendable { permission: String ) async throws(AccessControlError) -> Bool - /// Checks whether the ACL contains the given permission value. - /// - /// - Parameter permission: Permission value to test. - /// - Returns: `true` when the permission is present, otherwise `false`. - /// - Throws: Any error produced by the ACL backend. - func has( - permission: Permission - ) async throws(AccessControlError) -> Bool - /// Requires the ACL to contain the given role. /// /// - Parameter role: Required role key. @@ -49,29 +40,10 @@ public protocol AccessControlList: Sendable { func require( permission: String ) async throws(AccessControlError) - - /// Requires the ACL to contain the given permission value. - /// - /// - Parameter permission: Required permission. - /// - Throws: ``AccessControlError/forbidden(_:)`` when the permission is missing. - func require( - permission: Permission - ) async throws(AccessControlError) } extension AccessControlList { - /// Checks whether the ACL contains the given permission value. - /// - /// - Parameter permission: Permission value to test. - /// - Returns: `true` when the permission is present, otherwise `false`. - /// - Throws: Any error produced by `has(permission:)`. - public func has( - permission: Permission - ) async throws(AccessControlError) -> Bool { - try await has(permission: permission.rawValue) - } - /// Requires the ACL to contain the given role. /// /// - Parameter role: Required role key. @@ -105,14 +77,4 @@ extension AccessControlList { ) } } - - /// Requires the ACL to contain the given permission value. - /// - /// - Parameter permission: Required permission. - /// - Throws: ``AccessControlError/forbidden(_:)`` when the permission is missing. - public func require( - permission: Permission - ) async throws(AccessControlError) { - try await require(permission: permission.rawValue) - } } diff --git a/Sources/FeatherAccessControl/Permission.swift b/Sources/FeatherAccessControl/Permission.swift deleted file mode 100644 index 8ec6840..0000000 --- a/Sources/FeatherAccessControl/Permission.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// Permission.swift -// feather-access-control -// -// Created by Binary Birds on 2026. 02. 17. - -/// Generic permission object. -public struct Permission: Equatable, Hashable, Codable, Sendable { - - /// Default separator used between permission components. - public static let separator = "." - - /// Generic action for permissions. - public enum Action: RawRepresentable, Equatable, Codable, Sendable, - Hashable, ExpressibleByStringLiteral - { - - /// Action for list objects. - case list - /// Action for object details. - case detail - /// Action for creating new objects. - case create - /// Action for updating objects. - case update - /// Action for deleting objects. - case delete - /// Custom action key. - case custom(String) - - /// Creates an action from a raw key. - /// - /// - Parameter rawValue: Action key string. - public init( - rawValue: String - ) { - switch rawValue { - case "list": self = .list - case "detail": self = .detail - case "create": self = .create - case "update": self = .update - case "delete": self = .delete - default: self = .custom(rawValue) - } - } - - /// Raw key representation of the action. - public var rawValue: String { - switch self { - case .list: return "list" - case .detail: return "detail" - case .create: return "create" - case .update: return "update" - case .delete: return "delete" - case .custom(let key): return key - } - } - - /// Creates an action from a string literal. - /// - /// - Parameter value: Action key string. - public init( - stringLiteral value: StringLiteralType - ) { - self = .init(rawValue: value) - } - - /// Decodes an action from its raw string representation. - public init( - from decoder: Decoder - ) throws { - let container = try decoder.singleValueContainer() - self = .init(rawValue: try container.decode(String.self)) - } - - /// Encodes an action as its raw string representation. - public func encode( - to encoder: Encoder - ) throws { - var container = encoder.singleValueContainer() - try container.encode(rawValue) - } - } - - /// Namespace of the permission, usually the module name. - public let namespace: String - /// Context of the permission, usually a model name. - public let context: String - /// Action for the given namespace & context. - public let action: Action - /// The separator used to separate components. - public let separator: String - - /// Creates a new permission from namespace, context, and action. - /// - /// - Parameters: - /// - namespace: Namespace of the permission, typically a module name. - /// - context: Context of the permission, typically a model name. - /// - action: Action permitted for the given namespace and context. - /// - separator: The separator to use when converting to rawValue. - public init( - namespace: String, - context: String, - action: Action, - separator: String = Self.separator - ) { - self.namespace = namespace - self.context = context - self.action = action - self.separator = separator - } - - /// Creates a permission from a key with three components. - /// - /// The expected format is `namespace.context.action`. - /// - /// - Parameters: - /// - rawValue: Permission key to parse. - /// - separator: Separator used to split the permission key. - public init?( - rawValue: String, - separator: String = Self.separator - ) { - let parts = rawValue.split(separator: separator).map(String.init) - guard parts.count == 3 else { - return nil - } - self.namespace = parts[0] - self.context = parts[1] - self.action = .init(rawValue: parts[2]) - self.separator = separator - } - - /// Namespace, context, and action key components. - public var components: [String] { - [namespace, context, action.rawValue] - } - - /// Raw value using the namespace, context and action joined with the separator. - public var rawValue: String { - components.joined(separator: separator) - } -} diff --git a/Sources/FeatherAccessControl/PermissionCollection.swift b/Sources/FeatherAccessControl/PermissionCollection.swift deleted file mode 100644 index f5052c5..0000000 --- a/Sources/FeatherAccessControl/PermissionCollection.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// PermissionCollection.swift -// feather-access-control -// -// Created by Binary Birds on 2026. 02. 17. - -/// Describes a static collection of permissions for a domain or module. -public protocol PermissionCollection { - /// Complete list of permissions defined by the set. - static var permissions: [Permission] { get } -} diff --git a/Tests/FeatherAccessControlTests/PermissionTestSuite.swift b/Tests/FeatherAccessControlTests/PermissionTestSuite.swift deleted file mode 100644 index 42bbb66..0000000 --- a/Tests/FeatherAccessControlTests/PermissionTestSuite.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// PermissionTestSuite.swift -// feather-access-control -// -// Created by Binary Birds on 2026. 02. 17. - -import Foundation -import Testing - -@testable import FeatherAccessControl - -@Suite -struct PermissionTestSuite { - - @Test - func testPermissionFromPartsBuildsDerivedKeys() { - let permission = Permission( - namespace: "app", - context: "article", - action: "create", - separator: ":" - ) - - #expect(permission.components == ["app", "article", "create"]) - #expect(permission.action == .create) - #expect(permission.rawValue == "app:article:create") - } - - @Test - func testPermissionFromPartsBuildsDerivedKeysCustomAction() { - let permission = Permission( - namespace: "app", - context: "article", - action: "exists", - separator: "_" - ) - - #expect(permission.components == ["app", "article", "exists"]) - #expect(permission.action == .custom("exists")) - #expect(permission.rawValue == "app_article_exists") - } - - @Test - func testPermissionFromKeyParsesKnownAction() { - guard let permission = Permission(rawValue: "app.article.update") else { - Issue.record("Permission should exist.") - return - } - - #expect(permission.namespace == "app") - #expect(permission.context == "article") - #expect(permission.action == .update) - #expect(permission.rawValue == "app.article.update") - } - - @Test - func testPermissionFromKeyParsesCustomAction() { - guard let permission = Permission(rawValue: "app.article.publish") - else { - Issue.record("Permission should exist.") - return - } - - #expect(permission.action == .custom("publish")) - #expect(permission.rawValue == "app.article.publish") - } - - @Test - func testActionRawKeyMapping() { - #expect(Permission.Action("list") == .list) - #expect(Permission.Action("detail") == .detail) - #expect(Permission.Action("create") == .create) - #expect(Permission.Action("update") == .update) - #expect(Permission.Action("delete") == .delete) - #expect(Permission.Action("sync") == .custom("sync")) - } - - @Test - func testPermissionCodableRoundtrip() throws { - let permission = Permission( - namespace: "app", - context: "article", - action: .custom("publish") - ) - let encoded = try JSONEncoder().encode(permission) - let decoded = try JSONDecoder().decode(Permission.self, from: encoded) - - #expect(decoded == permission) - } - - @Test - func testACLHasPermissionUsingPermissionValue() async throws { - let permission = Permission( - namespace: "app", - context: "article", - action: .delete - ) - let acl = ACL(accountId: "test-id", permissions: [permission.rawValue]) - - let hasPermission = try await acl.has(permission: permission) - #expect(hasPermission) - } - - @Test - func testACLRequirePermissionThrowsForbiddenWhenMissing() async throws { - let acl = ACL(accountId: "test-id") - let permission = Permission( - namespace: "app", - context: "article", - action: .delete - ) - - do { - try await acl.require(permission: permission) - Issue.record("Expected forbidden permission error.") - } - catch AccessControlError.forbidden(let state) { - #expect(state.kind == .permission) - #expect(state.key == permission.rawValue) - } - catch { - Issue.record("Unexpected error type.") - } - } -} From 3379e2bacc67abffd13ba8a006c6ef0dca5ef82e Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 20 Feb 2026 13:06:22 +0100 Subject: [PATCH 6/6] rename account id, add user info to acl --- README.md | 2 +- Sources/FeatherAccessControl/ACL.swift | 15 ++++++++++----- .../FeatherAccessControlTestSuite.swift | 8 ++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 48d2f80..9d19e74 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Define account identifier, roles and permissions using an ACL object: import FeatherAccessControl let acl = ACL( - accountId: "user-account-123", + account: "user-account-123", roles: [ "editor" ], diff --git a/Sources/FeatherAccessControl/ACL.swift b/Sources/FeatherAccessControl/ACL.swift index 26d403e..859a90d 100644 --- a/Sources/FeatherAccessControl/ACL.swift +++ b/Sources/FeatherAccessControl/ACL.swift @@ -8,26 +8,31 @@ public struct ACL: AccessControlList { /// Identifier of the account the ACL belongs to. - public let accountId: String + public let account: String /// Role keys granted to the account. public let roles: [String] /// Permission keys granted to the account. public let permissions: [String] + /// Additional user information. + public let userInfo: [String: String] /// Creates a new ACL value. /// /// - Parameters: - /// - accountId: Identifier of the account. + /// - account: Identifier of the account. /// - roles: Role keys available to the account. /// - permissions: Permission keys available to the account. + /// - userInfo: Additional user information. public init( - accountId: String, + account: String, roles: [String] = [], - permissions: [String] = [] + permissions: [String] = [], + userInfo: [String: String] = [:] ) { - self.accountId = accountId + self.account = account self.roles = roles self.permissions = permissions + self.userInfo = userInfo } /// Checks whether the ACL contains the given role key. diff --git a/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift b/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift index 6e45f1e..c997f55 100644 --- a/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift +++ b/Tests/FeatherAccessControlTests/FeatherAccessControlTestSuite.swift @@ -23,7 +23,7 @@ struct FeatherAccessControlTestSuite { @Test func testSetACL() async throws { let acl = ACL( - accountId: "test-id", + account: "test-id", roles: ["test-role"], permissions: ["test-permission"] ) @@ -48,7 +48,7 @@ struct FeatherAccessControlTestSuite { @Test func testRequireACL() async throws { let acl = ACL( - accountId: "test-id", + account: "test-id", roles: ["test-role"], permissions: ["test-permission"] ) @@ -76,7 +76,7 @@ struct FeatherAccessControlTestSuite { @Test func testACLForbiddenRoleError() async throws { - let acl = ACL(accountId: "test-id") + let acl = ACL(account: "test-id") do { try await AccessControl.set(acl) { @@ -96,7 +96,7 @@ struct FeatherAccessControlTestSuite { @Test func testACLForbiddenPermissionError() async throws { - let acl = ACL(accountId: "test-id") + let acl = ACL(account: "test-id") do { try await AccessControl.set(acl) {