From 51c646140121af1ee5fffe5cafe8c851d7317aca Mon Sep 17 00:00:00 2001 From: Saoirse Aronson Date: Wed, 20 May 2026 13:13:43 +0200 Subject: [PATCH] An extended query API that can extract information Several related changes extend the query API to make it capable of extracting information from the token, similar to Rust's query API. Both Authorization and Biscuit gain new methods: queryValues and queryValuesExpectingOne. These take a query somewhat similar to the existing boolean query APIs, but also have a result parameter to specify which results should be taken out of the token. The result parameter is a repeated sequence of tuples containing a variable name as well as a type; the return value of these APIs is an array of tuples or just a tuple containing the values that satisfy that query. For example: ```swift let (userID, serviceName) = token.queryValuesExpectingOne( result: ("u", Data.self), ("s", String.self) ) { Predicate("user", Term(variable: "u")) Predicate("service", Term(variable: "s")) } ``` Like rules, checks and policies these respect trusting scopes and will only return results that can be derived from the specified trusting scope (authority by default). Because multiple values could possibly satisfy the query, queryValues returns an array of tuples; if it would be an error for a token or authorization to satisfy the query multiple times, queryValuesExpectingOne will throw an error in that case and returns a single tuple. To support querying an authorization, the Authorization type returned by authorizing a biscuit now also stores the fact resolution that occurred during authorization so that it can support querying facts about the authorization (incorporating facts from both the biscuit and the authorizer used to authorize it). Some refactors in Resolution were done because of this as well. To support downcasting Value and MapKey to specific types (ints, strings, etc), new protocols ExpressibleByValue and ExpressibleByMapKey are added, with conformances for all of the standard value types. These could also be used to support down casting values to domain specific types in applications (for example encoding enums as integers). --- Sources/Biscuits/Authorization.swift | 117 +++++++++ Sources/Biscuits/Biscuit.swift | 70 ++++- Sources/Biscuits/Datalog/Authorizer.swift | 3 +- Sources/Biscuits/Datalog/MapKey.swift | 30 ++- Sources/Biscuits/Datalog/Query.swift | 1 + Sources/Biscuits/Datalog/Resolution.swift | 305 ++++++++++++++-------- Sources/Biscuits/Datalog/Value.swift | 88 ++++++- Sources/Biscuits/Error.swift | 41 +++ Tests/BiscuitsTests/PublicAPITests.swift | 181 +++++++++++++ 9 files changed, 708 insertions(+), 128 deletions(-) create mode 100644 Sources/Biscuits/Authorization.swift 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")]) + } }