diff --git a/Sources/Biscuits/Authorization.swift b/Sources/Biscuits/Authorization.swift new file mode 100644 index 0000000..0fb4d51 --- /dev/null +++ b/Sources/Biscuits/Authorization.swift @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation. + * SPDX-License-Identifier: Apache-2.0 + */ + +extension Biscuit { + /// The result of a successful authorization check on a Biscuit + public struct Authorization: Sendable, Hashable { + /// Which policy statement passed, resulting in the successful authorization + public let successfulPolicy: Policy + + let resolution: Resolution + + init(policy: Policy, resolution: Resolution) { + self.successfulPolicy = policy + self.resolution = resolution + } + + /// Query the authorization to check if a certain statement holds true + /// - Parameter check: The Check to use to query the authorization + /// - Returns: Whether or not the query succeeded + /// - Throws: Throws an `EvaluationError` if the query cannot be evaluated + public func query(using check: Check) throws -> Bool { + try check.validate(self.resolution, [0], .authorizer) + } + + /// Query the authorization to check if a certain statement holds true + /// - Parameter kind: What kind of check to perform + /// - Parameter trusting: identities to trust when evaluating this query + /// - Parameter predicates: The predicates of this query + /// - Returns: Whether or not the query succeeded + /// - Throws: Throws an `EvaluationError` if the query cannot be evaluated + public func query( + kind: Check.Kind = .checkIf, + trusting: repeat each T, + @Biscuit.StatementBuilder predicates: () -> Biscuit.StatementBuilder + ) throws -> Bool { + var scopes: [TrustedScope] = [] + repeat scopes.append((each trusting).trustedScope) + return try self.query(using: Check(kind: kind, trusting: scopes, predicates())) + } + + /// Query the authorization to check if a certain statement holds true + /// - Parameter datalog: The check to use to query the authorization, as a String + /// - Returns: Whether or not the query succeeded + /// - Throws: Throws an `EvaluationError` if the query cannot be evaluated and parsing the + /// Datalog may throw a `DatalogError` + public func query(using datalog: String) throws -> Bool { + try self.query(using: Check(datalog)) + } + + /// Query the authorization to extract values from it + /// + /// Some number of names and types is passed as the "result" parameter; these should be the + /// names of variables that appear in the predicates of the query. This query will return + /// tuples of values that satisfy the predicates when bound to those names. + /// + /// - Parameter result: a sequence of tuples containing the name of a variable used in the + /// query and the type that variable is expected to be + /// - Parameter trusting: identities to trust when evaluating this query + /// - Parameter predicates: The predicates of this query + /// - Returns: An array of all the tuples of values which satisfy the predicates + /// - Throws: Throws an `EvaluationError` if the query cannot be evaluated, an + /// `InvalidQueryError` if the query is invalid or an `InvalidValueError` if a value does + /// not have the expected type + public func queryValues( + result: repeat (String, (each V).Type), + trusting: repeat each T, + @Biscuit.StatementBuilder predicates: () throws -> Biscuit.StatementBuilder + ) throws -> [(repeat each V)] { + let query = try predicates() + var scopes: [TrustedScope] = [] + repeat scopes.append((each trusting).trustedScope) + let trusted = self.resolution.trustScopes(scopes, nil) + let variables = try self.resolution.queryValues(query.predicates, query.expressions, trusted) + return try variables.map { vars in + (repeat try unpackVariable(vars, (each result).0, (each result).1)) + } + } + + /// Query the authorization to extract values from it, expecting only one tuple of matching + /// values. + /// + /// Some number of names and types is passed as the "result" parameter; these should be the + /// names of variables that appear in the predicates of the query. This query will return + /// tuples of values that satisfy the predicates when bound to those names. + /// + /// - Parameter result: a sequence of tuples containing the name of a variable used in the + /// query and the type that variable is expected to be + /// - Parameter trusting: identities to trust when evaluating this query + /// - Parameter predicates: The predicates of this query + /// - Returns: The tuples of values which satisfy the predicates + /// - Throws: Throws an `EvaluationError` if the query cannot be evaluated, an + /// `InvalidQueryError` if the query is invalid or returns too many or too few results, or + /// an `InvalidValueError` if a value does not have the expected type + public func queryValuesExpectingOne( + result: repeat (String, (each V).Type), + trusting: repeat each T, + @Biscuit.StatementBuilder predicates: () throws -> Biscuit.StatementBuilder + ) throws -> (repeat each V) { + let query = try predicates() + var scopes: [TrustedScope] = [] + repeat scopes.append((each trusting).trustedScope) + let trusted = self.resolution.trustScopes(scopes, nil) + let variables = try self.resolution.queryValuesExpectingOne(query.predicates, query.expressions, trusted) + return (repeat try unpackVariable(variables, (each result).0, (each result).1)) + } + } +} + +func unpackVariable(_ vars: [String: Value], _ name: String, _ ty: V.Type) throws -> V { + if let value = vars[name] { + return try ty.init(value: value) + } else { + throw Biscuit.InvalidQueryError.missingVariable + } +} diff --git a/Sources/Biscuits/Biscuit.swift b/Sources/Biscuits/Biscuit.swift index ff8b416..0c40045 100644 --- a/Sources/Biscuits/Biscuit.swift +++ b/Sources/Biscuits/Biscuit.swift @@ -443,16 +443,6 @@ public struct Biscuit: Sendable, Hashable { public var debugDescription: String { String(reflecting: self.rawValue) } } - /// The result of a successful authorization check on a Biscuit - public struct Authorization: Sendable, Hashable { - /// Which policy statement passed, resulting in the successful authorization - public let successfulPolicy: Policy - - init(policy: Policy) { - self.successfulPolicy = policy - } - } - /// Query the biscuit to check if a certain statement holds true /// - Parameter check: The Check to use to query the biscuit /// - Parameter limitedBy: Limitations on the runtime for this query @@ -503,4 +493,64 @@ public struct Biscuit: Sendable, Hashable { var lastBlock: Block { self.attenuations.last ?? self.authority } + /// Query the biscuit to extract values from it + /// + /// Some number of names and types is passed as the "result" parameter; these should be the + /// names of variables that appear in the predicates of the query. This query will return + /// tuples of values that satisfy the predicates when bound to those names. + /// + /// - Parameter result: a sequence of tuples containing the name of a variable used in the + /// query and the type that variable is expected to be + /// - Parameter trusting: identities to trust when evaluating this query + /// - Parameter predicates: The predicates of this query + /// - Returns: An array of all the tuples of values which satisfy the predicates + /// - Throws: Throws an `AuthorizationError` if the biscuit does not pass authorization, or an + /// `EvaluationError` if the query cannot be evaluated, an `InvalidQueryError` if the query is + /// invalid or an `InvalidValueError` if a value does not have the expected type + public func queryValues( + result: repeat (String, (each V).Type), + trusting: repeat each T, + limitedBy: Authorizer.Limits = Authorizer.Limits.noLimits, + @Biscuit.StatementBuilder predicates: () throws -> Biscuit.StatementBuilder + ) throws -> [(repeat each V)] { + let resolution = try Resolution(biscuit: self, authorizer: Authorizer(limits: limitedBy)) + let query = try predicates() + var scopes: [TrustedScope] = [] + repeat scopes.append((each trusting).trustedScope) + let trusted = resolution.trustScopes(scopes, nil) + let variables = try resolution.queryValues(query.predicates, query.expressions, trusted) + return try variables.map { vars in + (repeat try unpackVariable(vars, (each result).0, (each result).1)) + } + } + + /// Query the biscuit to extract values from it, expecting only one tuple of matching values. + /// + /// Some number of names and types is passed as the "result" parameter; these should be the + /// names of variables that appear in the predicates of the query. This query will return tuples + /// of values that satisfy the predicates when bound to those names. + /// + /// - Parameter result: a sequence of tuples containing the name of a variable used in the + /// query and the type that variable is expected to be + /// - Parameter trusting: identities to trust when evaluating this query + /// - Parameter predicates: The predicates of this query + /// - Returns: The tuples of values which satisfy the predicates + /// - Throws: Throws an `AuthorizationError` if the biscuit does not pass authorization, or an + /// `EvaluationError` if the query cannot be evaluated, an `InvalidQueryError` if the query is + /// invalid or returns too many or too few results, or an `InvalidValueError` if a value does + /// not have the expected type + public func queryValuesExpectingOne( + result: repeat (String, (each V).Type), + trusting: repeat each T, + limitedBy: Authorizer.Limits = Authorizer.Limits.noLimits, + @Biscuit.StatementBuilder predicates: () throws -> Biscuit.StatementBuilder + ) throws -> (repeat each V) { + let resolution = try Resolution(biscuit: self, authorizer: Authorizer(limits: limitedBy)) + let query = try predicates() + var scopes: [TrustedScope] = [] + repeat scopes.append((each trusting).trustedScope) + let trusted = resolution.trustScopes(scopes, nil) + let variables = try resolution.queryValuesExpectingOne(query.predicates, query.expressions, trusted) + return (repeat try unpackVariable(variables, (each result).0, (each result).1)) + } } diff --git a/Sources/Biscuits/Datalog/Authorizer.swift b/Sources/Biscuits/Datalog/Authorizer.swift index 9899167..85ef556 100644 --- a/Sources/Biscuits/Datalog/Authorizer.swift +++ b/Sources/Biscuits/Datalog/Authorizer.swift @@ -2,6 +2,7 @@ * Copyright (c) 2025 Contributors to the Eclipse Foundation. * SPDX-License-Identifier: Apache-2.0 */ + extension Biscuit { func validateChecks(_ resolution: Resolution) throws { try self.authority.validateChecks(resolution, .block(0)) @@ -202,7 +203,7 @@ extension Policy { if try resolution.checkQueryIf(query, trusted) { switch self.kind.wrapped { case .allow: - return Biscuit.Authorization(policy: self) + return Biscuit.Authorization(policy: self, resolution: resolution) case .deny: throw Biscuit.AuthorizationError(deny: self) } diff --git a/Sources/Biscuits/Datalog/MapKey.swift b/Sources/Biscuits/Datalog/MapKey.swift index 0652692..7172d62 100644 --- a/Sources/Biscuits/Datalog/MapKey.swift +++ b/Sources/Biscuits/Datalog/MapKey.swift @@ -9,7 +9,8 @@ import Foundation #endif /// A Value that can be used as a key in a map -public struct MapKey: MapKeyConvertible, ValueConvertible, TermConvertible, ExpressionConvertible, Hashable, Sendable, +public struct MapKey: ExpressibleByMapKey, MapKeyConvertible, ValueConvertible, TermConvertible, ExpressionConvertible, + Hashable, Sendable, CustomStringConvertible { internal enum Wrapped: Hashable { @@ -37,6 +38,10 @@ public struct MapKey: MapKeyConvertible, ValueConvertible, TermConvertible, Expr self.wrapped = .string(string) } + public init(mapKey: MapKey) throws { + self = mapKey + } + init(_ wrapped: Wrapped) { self.wrapped = wrapped } @@ -72,10 +77,29 @@ public protocol MapKeyConvertible: ValueConvertible { var mapKey: MapKey { get } } -extension Int: MapKeyConvertible { +/// Anything which can be expressed as a MapKey +public protocol ExpressibleByMapKey { + init(mapKey: MapKey) throws +} + +extension Int: ExpressibleByMapKey, MapKeyConvertible { + public init(mapKey: MapKey) throws { + switch mapKey.wrapped { + case .integer(let int): self = Int(int) + default: throw Biscuit.InvalidValueError(expected: Int.self) + } + } + public var mapKey: MapKey { MapKey(self) } } -extension String: MapKeyConvertible { +extension String: ExpressibleByMapKey, MapKeyConvertible { + public init(mapKey: MapKey) throws { + switch mapKey.wrapped { + case .string(let string): self = string + default: throw Biscuit.InvalidValueError(expected: String.self) + } + } + public var mapKey: MapKey { MapKey(self) } } diff --git a/Sources/Biscuits/Datalog/Query.swift b/Sources/Biscuits/Datalog/Query.swift index ade3ff6..b69c862 100644 --- a/Sources/Biscuits/Datalog/Query.swift +++ b/Sources/Biscuits/Datalog/Query.swift @@ -2,6 +2,7 @@ * Copyright (c) 2025 Contributors to the Eclipse Foundation. * SPDX-License-Identifier: Apache-2.0 */ + extension Biscuit { /// A query about a Biscuit that can be true or false. public struct Query: Sendable, Hashable, CustomStringConvertible { diff --git a/Sources/Biscuits/Datalog/Resolution.swift b/Sources/Biscuits/Datalog/Resolution.swift index 29c417a..ee8acf1 100644 --- a/Sources/Biscuits/Datalog/Resolution.swift +++ b/Sources/Biscuits/Datalog/Resolution.swift @@ -2,76 +2,214 @@ * Copyright (c) 2025 Contributors to the Eclipse Foundation. * SPDX-License-Identifier: Apache-2.0 */ -struct Resolution { - var stable: [FactId: Set] = [:] - var recent: [FactId: Set] = [:] - var new: [FactId: Set] = [:] - var publicKeys: [Biscuit.ThirdPartyKey: Set] = [:] - init(biscuit: Biscuit, authorizer: Biscuit.Authorizer) throws { - var factCount = 0 - let authority = biscuit.authority - factCount += try authority.addFacts(to: &self, in: 0) - for (index, block) in biscuit.attenuations.enumerated() { - let blockID = index + 1 - factCount += try block.addFacts(to: &self, in: blockID) - if let signature = block.externalSignature { - self.publicKeys[signature.publicKey, default: []].insert(blockID) +struct Resolution: Hashable { + let facts: [FactId: Set] + let publicKeys: [Biscuit.ThirdPartyKey: Set] + + struct ResolutionBuilder { + var stable: [FactId: Set] = [:] + var recent: [FactId: Set] = [:] + var new: [FactId: Set] = [:] + var publicKeys: [Biscuit.ThirdPartyKey: Set] = [:] + + init(biscuit: Biscuit, authorizer: Biscuit.Authorizer) throws { + var factCount = 0 + let authority = biscuit.authority + factCount += try authority.addFacts(to: &self, in: 0) + for (index, block) in biscuit.attenuations.enumerated() { + let blockID = index + 1 + factCount += try block.addFacts(to: &self, in: blockID) + if let signature = block.externalSignature { + self.publicKeys[signature.publicKey, default: []].insert(blockID) + } + } + factCount += try authorizer.addFacts(to: &self) + + var iterationCount = 0 + while !self.recent.isEmpty { + try self.applyRules(authority.datalog.rules, authority.datalog.trusted, .block(0)) + for (index, block) in biscuit.attenuations.enumerated() { + try self.applyRules( + block.datalog.rules, + block.datalog.trusted, + .block(index + 1) + ) + } + try self.applyRules(authorizer.rules, [], .authorizer) + for (id, recent) in self.recent { + self.stable[id, default: []].formUnion(recent) + } + self.recent = self.new + self.new = [:] + factCount += self.recent.map { $1.count }.reduce(0, +) + iterationCount += 1 + guard authorizer.limits.maximumFacts.map({ $0 >= factCount }) ?? true else { + throw Biscuit.EvaluationError.tooManyFacts + } + guard authorizer.limits.maximumIterations.map({ $0 >= iterationCount }) ?? true else { + throw Biscuit.EvaluationError.tooManyIterations + } } } - factCount += try authorizer.addFacts(to: &self) - var iterationCount = 0 - while !self.recent.isEmpty { - try self.applyRules(authority.datalog.rules, authority.datalog.trusted, .block(0)) - for (index, block) in biscuit.attenuations.enumerated() { - try self.applyRules( - block.datalog.rules, - block.datalog.trusted, - .block(index + 1) - ) + mutating func applyRules(_ rules: [Rule], _ trusted: [TrustedScope], _ scope: Scope) throws { + let trusted = self.trustScopes(trusted, scope.blockID) + for rule in rules { + let trusted = rule.trusted.isEmpty ? trusted : self.trustScopes(rule.trusted, scope.blockID) + try self.applyRule(rule, scope, trusted) } - try self.applyRules(authorizer.rules, [], .authorizer) - for (id, recent) in self.recent { - self.stable[id, default: []].formUnion(recent) + } + + mutating func applyRule(_ rule: Rule, _ scope: Scope, _ trusted: Set) throws { + for index in rule.bodyPredicates.indices { + let predicate = rule.bodyPredicates[index] + for recentVariables in self.recentFactsThatSupport(predicate, trusted) { + let variables = self.collectAllVariables( + rule.bodyPredicates, + trusted, + recentVariables, + skipping: index + ) + for variables in variables { + if try rule.expressions.allSatisfy({ expr in try expr.evaluate(variables) }) { + try self.addFact(rule.head.makeConcrete(variables: variables), scope) + } + } + } } - self.recent = self.new - self.new = [:] - factCount += self.recent.map { $1.count }.reduce(0, +) - iterationCount += 1 - guard authorizer.limits.maximumFacts.map({ $0 >= factCount }) ?? true else { - throw Biscuit.EvaluationError.tooManyFacts + } + + mutating func addFact(_ fact: Fact, _ scope: Scope) { + let factID = FactId(fact, scope) + guard + self.stable[factID]?.contains(fact) != true + && self.recent[factID]?.contains(fact) != true + else { return } + self.new[factID, default: []].insert(fact) + } + + func collectAllVariables( + _ predicates: [Predicate], + _ trusted: Set, + _ variables: [String: Value], + skipping: Int + ) -> [[String: Value]] { + var result = [variables] + for index in predicates.indices where index != skipping { + result = result.flatMap { self.allFactsThatSupport(predicates[index], trusted, $0) } + } + return result + } + + func recentFactsThatSupport( + _ predicate: Predicate, + _ trusted: Set, + _ vars: [String: Value] = [:] + ) + -> [[String: Value]] + { + var relevantFacts: Set = self.recent[FactId(predicate, .authorizer)] ?? [] + for blockID in trusted { + relevantFacts.formUnion(self.recent[FactId(predicate, .block(blockID))] ?? []) + } + return relevantFacts.compactMap { $0.supportsWithVariables(predicate, vars) } + } + + func stableFactsThatSupport( + _ predicate: Predicate, + _ trusted: Set, + _ vars: [String: Value] = [:] + ) + -> [[String: Value]] + { + var relevantFacts: Set = self.stable[FactId(predicate, .authorizer)] ?? [] + for blockID in trusted { + relevantFacts.formUnion(self.stable[FactId(predicate, .block(blockID))] ?? []) + } + return relevantFacts.compactMap { $0.supportsWithVariables(predicate, vars) } + } + + func allFactsThatSupport( + _ predicate: Predicate, + _ trusted: Set, + _ vars: [String: Value] = [:] + ) + -> [[String: Value]] + { + var facts = self.stableFactsThatSupport(predicate, trusted, vars) + facts.append(contentsOf: self.recentFactsThatSupport(predicate, trusted, vars)) + return facts + } + + func trustScopes(_ scopes: [TrustedScope], _ blockID: Int?) -> Set { + var trusted: Set = [] + if scopes.isEmpty { + trusted.insert(0) } - guard authorizer.limits.maximumIterations.map({ $0 >= iterationCount }) ?? true else { - throw Biscuit.EvaluationError.tooManyIterations + if let blockID = blockID { + trusted.insert(blockID) } + for scope in scopes { + switch scope.wrapped { + case .authority: + trusted.insert(0) + case .previous: + if let blockID = blockID { + trusted.formUnion(0.. + ) throws -> [[String: Value]] { + try self.collectVariables(predicates[...], trusted, [:]).filter { variables in + try expressions.allSatisfy({ try $0.evaluate(variables) }) } } - mutating func applyRule(_ rule: Rule, _ scope: Scope, _ trusted: Set) throws { - for predicate in rule.bodyPredicates { - for variables in self.recentFactsThatSupport(predicate, trusted) { - let predicates = rule.bodyPredicates.filter { $0 != predicate } - for variables in self.collectAllVariables(predicates[...], trusted, variables) { - if try rule.expressions.allSatisfy({ expr in try expr.evaluate(variables) }) { - try self.addFact(rule.head.makeConcrete(variables: variables), scope) - } + func queryValuesExpectingOne( + _ predicates: [Predicate], + _ expressions: [Expression], + _ trusted: Set + ) throws -> [String: Value] { + let variables = self.collectVariables(predicates[...], trusted, [:]) + var found: [String: Value]? = nil + for vars in variables { + if try expressions.allSatisfy({ try $0.evaluate(vars) }) { + guard found == nil else { + throw Biscuit.InvalidQueryError.tooManyResults } + found = vars } } + if let found = found { + return found + } else { + throw Biscuit.InvalidQueryError.notEnoughResults + } } func checkQueryIf(_ query: Biscuit.Query, _ trusted: Set) throws -> Bool { var success = false - for variables in self.collectStableVariables(query.predicates[...], trusted, [:]) { + for variables in self.collectVariables(query.predicates[...], trusted, [:]) { success = try query.expressions.allSatisfy({ try $0.evaluate(variables) }) if success { break } } @@ -80,77 +218,39 @@ struct Resolution { func checkQueryAll(_ query: Biscuit.Query, _ trusted: Set) throws -> Bool { var success = false - for variables in self.collectStableVariables(query.predicates[...], trusted, [:]) { + for variables in self.collectVariables(query.predicates[...], trusted, [:]) { success = try query.expressions.allSatisfy({ try $0.evaluate(variables) }) guard success else { break } } return success } - func collectStableVariables( - _ predicates: ArraySlice, - _ trusted: Set, - _ variables: [String: Value] - ) -> [[String: Value]] { - guard !predicates.isEmpty else { return [variables] } - let start = predicates.startIndex - return self.stableFactsThatSupport(predicates[start], trusted, variables).flatMap { - self.collectStableVariables(predicates[(start + 1)...], trusted, $0) - } - } - - func collectAllVariables( + func collectVariables( _ predicates: ArraySlice, _ trusted: Set, _ variables: [String: Value] ) -> [[String: Value]] { guard !predicates.isEmpty else { return [variables] } let start = predicates.startIndex - return self.allFactsThatSupport(predicates[start], trusted, variables).flatMap { - self.collectAllVariables(predicates[(start + 1)...], trusted, $0) - } - } - - func recentFactsThatSupport( - _ predicate: Predicate, - _ trusted: Set, - _ vars: [String: Value] = [:] - ) - -> [[String: Value]] - { - var relevantFacts: Set = self.recent[FactId(predicate, .authorizer)] ?? [] - for blockID in trusted { - relevantFacts.formUnion(self.recent[FactId(predicate, .block(blockID))] ?? []) + return self.factsThatSupport(predicates[start], trusted, variables).flatMap { + self.collectVariables(predicates[(start + 1)...], trusted, $0) } - return relevantFacts.compactMap { $0.supportsWithVariables(predicate, vars) } } - func stableFactsThatSupport( + func factsThatSupport( _ predicate: Predicate, _ trusted: Set, _ vars: [String: Value] = [:] ) -> [[String: Value]] { - var relevantFacts: Set = self.stable[FactId(predicate, .authorizer)] ?? [] + var relevantFacts: Set = self.facts[FactId(predicate, .authorizer)] ?? [] for blockID in trusted { - relevantFacts.formUnion(self.stable[FactId(predicate, .block(blockID))] ?? []) + relevantFacts.formUnion(self.facts[FactId(predicate, .block(blockID))] ?? []) } return relevantFacts.compactMap { $0.supportsWithVariables(predicate, vars) } } - func allFactsThatSupport( - _ predicate: Predicate, - _ trusted: Set, - _ vars: [String: Value] = [:] - ) - -> [[String: Value]] - { - var facts = self.stableFactsThatSupport(predicate, trusted, vars) - facts.append(contentsOf: self.recentFactsThatSupport(predicate, trusted, vars)) - return facts - } - func trustScopes(_ scopes: [TrustedScope], _ blockID: Int?) -> Set { var trusted: Set = [] if scopes.isEmpty { @@ -165,9 +265,7 @@ struct Resolution { trusted.insert(0) case .previous: if let blockID = blockID { - for id in 0.. Int { + fileprivate func addFacts(to facts: inout Resolution.ResolutionBuilder, in blockID: Int) throws -> Int { let revocationID = Fact(index: blockID, revocationID: self.signature) facts.recent[Resolution.FactId(revocationID, .authorizer), default: []].insert(revocationID) for fact in self.datalog.facts { @@ -232,7 +321,7 @@ extension Biscuit.Block { } extension Biscuit.Authorizer { - fileprivate func addFacts(to facts: inout Resolution) throws -> Int { + fileprivate func addFacts(to facts: inout Resolution.ResolutionBuilder) throws -> Int { for fact in self.facts { facts.recent[Resolution.FactId(fact, .authorizer), default: []].insert(fact) } diff --git a/Sources/Biscuits/Datalog/Value.swift b/Sources/Biscuits/Datalog/Value.swift index 5b3d7ce..a03da56 100644 --- a/Sources/Biscuits/Datalog/Value.swift +++ b/Sources/Biscuits/Datalog/Value.swift @@ -9,7 +9,7 @@ import Foundation #endif /// A Datalog literal value; a `Term` which is not a variable -public struct Value: ValueConvertible, TermConvertible, ExpressionConvertible, Sendable, Hashable, +public struct Value: ExpressibleByValue, ValueConvertible, TermConvertible, ExpressionConvertible, Sendable, Hashable, CustomStringConvertible { enum Wrapped: Hashable { @@ -50,6 +50,10 @@ public struct Value: ValueConvertible, TermConvertible, ExpressionConvertible, S self.wrapped = .bool(bool) } + public init(value: Value) throws { + self = value + } + /// The null Value public static var null: Value { Value() } @@ -695,22 +699,94 @@ public protocol ValueConvertible: TermConvertible { var value: Value { get } } -extension Int: ValueConvertible, TermConvertible, ExpressionConvertible { +/// Anything which can be expressed as a Value +public protocol ExpressibleByValue { + init(value: Value) throws +} + +extension Int: ExpressibleByValue, ValueConvertible, TermConvertible, ExpressionConvertible { + public init(value: Value) throws { + switch value.wrapped { + case .integer(let int): self = Int(int) + default: throw Biscuit.InvalidValueError(expected: Int.self) + } + } + public var value: Value { Value(self) } } -extension String: ValueConvertible, TermConvertible, ExpressionConvertible { +extension String: ExpressibleByValue, ValueConvertible, TermConvertible, ExpressionConvertible { + public init(value: Value) throws { + switch value.wrapped { + case .string(let string): self = string + default: throw Biscuit.InvalidValueError(expected: String.self) + } + } + public var value: Value { Value(self) } } -extension Date: ValueConvertible, TermConvertible, ExpressionConvertible { +extension Date: ExpressibleByValue, ValueConvertible, TermConvertible, ExpressionConvertible { + public init(value: Value) throws { + switch value.wrapped { + case .date(let date): self = date + default: throw Biscuit.InvalidValueError(expected: Date.self) + } + } + public var value: Value { Value(self) } } -extension Data: ValueConvertible, TermConvertible, ExpressionConvertible { +extension Data: ExpressibleByValue, ValueConvertible, TermConvertible, ExpressionConvertible { + public init(value: Value) throws { + switch value.wrapped { + case .bytes(let data): self = data + default: throw Biscuit.InvalidValueError(expected: Data.self) + } + } + public var value: Value { Value(self) } } -extension Bool: ValueConvertible, TermConvertible, ExpressionConvertible { +extension Bool: ExpressibleByValue, ValueConvertible, TermConvertible, ExpressionConvertible { + public init(value: Value) throws { + switch value.wrapped { + case .bool(let boolean): self = boolean + default: throw Biscuit.InvalidValueError(expected: Bool.self) + } + } + public var value: Value { Value(self) } } + +extension Array: ExpressibleByValue where Element: ExpressibleByValue { + public init(value: Value) throws { + switch value.wrapped { + case .array(let array): self = try array.map { try Element(value: $0) } + default: throw Biscuit.InvalidValueError(expected: Array.self) + } + } +} + +extension Dictionary: ExpressibleByValue where Key: ExpressibleByMapKey, Value: ExpressibleByValue { + public init(value: Biscuits.Value) throws { + switch value.wrapped { + case .map(let map): + self = try Dictionary( + uniqueKeysWithValues: map.map { + (try Key(mapKey: $0), try Value(value: $1)) + } + ) + default: throw Biscuit.InvalidValueError(expected: Dictionary.self) + } + } +} + +extension Set: ExpressibleByValue where Element: ExpressibleByValue { + public init(value: Value) throws { + switch value.wrapped { + case .set(let set): self = try Set(set.map { try Element(value: $0) }) + default: throw Biscuit.InvalidValueError(expected: Set.self) + } + } +} diff --git a/Sources/Biscuits/Error.swift b/Sources/Biscuits/Error.swift index 23dfdd3..5df5337 100644 --- a/Sources/Biscuits/Error.swift +++ b/Sources/Biscuits/Error.swift @@ -233,6 +233,47 @@ extension Biscuit { } } + /// An error that occurred while evaluating a value query + public struct InvalidQueryError: Sendable, Hashable, Error, CustomStringConvertible { + internal enum ErrorCode: Hashable { + case missingVariable + case tooManyResults + case notEnoughResults + } + + let code: ErrorCode + + init(_ code: ErrorCode) { + self.code = code + } + + static var missingVariable: Self { Self(.missingVariable) } + static var tooManyResults: Self { Self(.tooManyResults) } + static var notEnoughResults: Self { Self(.notEnoughResults) } + + public var description: String { + switch self.code { + case .missingVariable: "variable missing from query" + case .tooManyResults: "multiple valid query results when only one was expected" + case .notEnoughResults: "no valid query results" + } + } + } + + /// An error that occurred while casting a value to a specific value type + public struct InvalidValueError: Sendable, Hashable, Error, CustomStringConvertible { + let expected: String + + public init(expected: V.Type) { + self.expected = "\(expected)" + } + + public var description: String { + "invalid value: expected \(expected)" + } + + } + /// An error that occurred while validating the serialized data representation of a Biscuit public struct ValidationError: Sendable, Hashable, Error, CustomStringConvertible { internal enum ErrorCode: Hashable { diff --git a/Tests/BiscuitsTests/PublicAPITests.swift b/Tests/BiscuitsTests/PublicAPITests.swift index 346c5e9..a63348d 100644 --- a/Tests/BiscuitsTests/PublicAPITests.swift +++ b/Tests/BiscuitsTests/PublicAPITests.swift @@ -196,4 +196,185 @@ final class PublicAPITests: XCTestCase { } } } + + func testBiscuitQueryValues() throws { + let signingKey = Curve25519.Signing.PrivateKey() + + let biscuit = try Biscuit(rootKey: signingKey) { + Fact("permission", 1234, "read") + Fact("permission", 5678, "write") + try Rule(head: Predicate("permission", Term(variable: "userID"), "read")) { + Predicate("permission", Term(variable: "userID"), "write") + } + } + + let results1 = try biscuit.queryValues( + result: ("userID", Int.self), + ("op", String.self) + ) { + Predicate("permission", Term(variable: "userID"), Term(variable: "op")) + } + + XCTAssertEqual(results1.count, 3) + XCTAssert(results1.contains(where: { $0 == (1234, "read") })) + XCTAssert(results1.contains(where: { $0 == (5678, "read") })) + XCTAssert(results1.contains(where: { $0 == (5678, "write") })) + + let results2 = try biscuit.queryValues(result: ("op", String.self)) { + Predicate("permission", 5678, Term(variable: "op")) + } + + XCTAssertEqual(results2.count, 2) + XCTAssert(results2.contains(where: { $0 == "read" })) + XCTAssert(results2.contains(where: { $0 == "write" })) + + let results3 = try biscuit.queryValues(result: ("userID", Int.self)) { + Predicate("permission", Term(variable: "userID"), "read") + } + + XCTAssertEqual(results3.count, 2) + XCTAssert(results3.contains(where: { $0 == 1234 })) + XCTAssert(results3.contains(where: { $0 == 5678 })) + + let results4 = try biscuit.queryValues(result: ("op", String.self)) { + Predicate("permission", 9012, Term(variable: "op")) + } + + XCTAssertEqual(results4, []) + } + + func testBiscuitQueryValuesExpectingOne() throws { + let signingKey = Curve25519.Signing.PrivateKey() + + let biscuit = try Biscuit(rootKey: signingKey) { + Fact("permission", 1234, "read") + Fact("permission", 5678, "write") + } + + let (op1) = try biscuit.queryValuesExpectingOne(result: ("op", String.self)) { + Predicate("permission", 1234, Term(variable: "op")) + } + XCTAssertEqual(op1, "read") + + let (op2) = try biscuit.queryValuesExpectingOne(result: ("op", String.self)) { + Predicate("permission", 5678, Term(variable: "op")) + } + XCTAssertEqual(op2, "write") + + let (userID) = try biscuit.queryValuesExpectingOne(result: ("userID", Int.self)) { + Predicate("permission", Term(variable: "userID"), "write") + } + XCTAssertEqual(userID, 5678) + } + + func testAuthorizationQueryValues() throws { + let signingKey = Curve25519.Signing.PrivateKey() + + let biscuit = try Biscuit(rootKey: signingKey) { + Fact("permission", 1234, "read") + Fact("permission", 5678, "write") + } + + let auth = try biscuit.authorize { + Fact("user", 5678) + try Rule(head: Predicate("permission", Term(variable: "userID"), "read")) { + Predicate("permission", Term(variable: "userID"), "write") + } + Policy.allowIf { + Predicate("user", Term(variable: "userID")) + Predicate("permission", Term(variable: "userID"), "read") + } + } + + let results1 = try auth.queryValues( + result: ("userID", Int.self), + ("op", String.self) + ) { + Predicate("permission", Term(variable: "userID"), Term(variable: "op")) + } + + XCTAssertEqual(results1.count, 3) + XCTAssert(results1.contains(where: { $0 == (1234, "read") })) + XCTAssert(results1.contains(where: { $0 == (5678, "read") })) + XCTAssert(results1.contains(where: { $0 == (5678, "write") })) + + let results2 = try auth.queryValues(result: ("op", String.self)) { + Predicate("permission", 5678, Term(variable: "op")) + } + + XCTAssertEqual(results2.count, 2) + XCTAssert(results2.contains(where: { $0 == "read" })) + XCTAssert(results2.contains(where: { $0 == "write" })) + + let results3 = try auth.queryValues(result: ("userID", Int.self)) { + Predicate("permission", Term(variable: "userID"), "read") + } + + XCTAssertEqual(results3.count, 2) + XCTAssert(results3.contains(where: { $0 == 1234 })) + XCTAssert(results3.contains(where: { $0 == 5678 })) + + let results4 = try auth.queryValues(result: ("op", String.self)) { + Predicate("permission", 9012, Term(variable: "op")) + } + + XCTAssertEqual(results4, []) + } + + func testAuthorizationQueryValuesExpectingOne() throws { + let signingKey = Curve25519.Signing.PrivateKey() + + let biscuit = try Biscuit(rootKey: signingKey) { + Fact("permission", 1234, "read") + Fact("permission", 5678, "write") + } + + let auth = try biscuit.authorize { + Fact("user", 1234) + try Rule(head: Predicate("permission", Term(variable: "userID"), "read")) { + Predicate("permission", Term(variable: "userID"), "write") + } + Policy.allowIf { + Predicate("user", Term(variable: "userID")) + Predicate("permission", Term(variable: "userID"), "read") + } + } + + let (userID) = try auth.queryValuesExpectingOne(result: ("userID", Int.self)) { + Predicate("user", Term(variable: "userID")) + } + XCTAssertEqual(userID, 1234) + + let (op) = try auth.queryValuesExpectingOne(result: ("op", String.self)) { + Predicate("user", Term(variable: "userID")) + Predicate("permission", Term(variable: "userID"), Term(variable: "op")) + } + XCTAssertEqual(op, "read") + } + + func testQueryValuesWithTrustedScopes() throws { + let signingKey = Curve25519.Signing.PrivateKey() + let thirdPartyKey = Curve25519.Signing.PrivateKey() + + let biscuit = try Biscuit(rootKey: signingKey) { + Fact("user", 1234) + } + + let attenuatedToken = try biscuit.attenuated(thirdPartyKey: thirdPartyKey) { + Fact("permission", "read") + } + + let results1 = try attenuatedToken.queryValues(result: ("op", String.self)) { + Predicate("permission", Term(variable: "op")) + } + XCTAssertEqual(results1, []) + + let results2 = try attenuatedToken.queryValues( + result: ("op", String.self), + trusting: thirdPartyKey.publicKey + ) { + Predicate("permission", Term(variable: "op")) + } + XCTAssertEqual(results2, [("read")]) + } }