Skip to content

Commit 10dbf5f

Browse files
Runtime: Support initial HTTP-based bundle sources (#17)
This commit implements support for the following HTTP-based bundle sources: - No auth HTTP/HTTPS (default) - Bearer auth Several config parsing bugs were also fixed. Signed-off-by: Philip Conrad <philip_conrad@apple.com>
1 parent d44109d commit 10dbf5f

13 files changed

Lines changed: 785 additions & 125 deletions

Package.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ let package = Package(
1616
],
1717
dependencies: [
1818
.package(url: "https://github.com/open-policy-agent/swift-opa", branch: "main"),
19+
.package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"),
20+
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.30.0"),
1921
.package(url: "https://github.com/swift-server/async-http-client", from: "1.21.0"),
2022
// TODO: Swap for whatever our solution ends up being. This is not the most recent commit,
2123
// but it is the last one that supports macOS 13 as a target.
@@ -65,7 +67,12 @@ let package = Package(
6567
),
6668
.testTarget(
6769
name: "RuntimeTests",
68-
dependencies: ["Runtime"]
70+
dependencies: [
71+
"Runtime",
72+
.product(name: "NIOCore", package: "swift-nio"),
73+
.product(name: "NIOPosix", package: "swift-nio"),
74+
.product(name: "NIOHTTP1", package: "swift-nio"),
75+
]
6976
),
7077
]
7178
)

Sources/Config/BundleConfig.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ extension OPA {
168168
/// in separate top-level config sections and aren't available at decode
169169
/// time. The parent config calls this method during its resolution phase
170170
/// to produce a fully-populated instance.
171-
public func resolved(withKeys keys: [String: KeyConfig]) throws -> BundleSourceConfig {
171+
public func resolved(withKeys keys: [String: KeyConfig], name: String) throws -> BundleSourceConfig {
172172
var resolvedSigning = try self.signing?.resolved(withKeys: keys)
173173
if !keys.isEmpty && resolvedSigning == nil {
174174
resolvedSigning = BundleVerificationConfig(publicKeys: keys, keyID: "", scope: "", exclude: [])
@@ -177,7 +177,7 @@ extension OPA {
177177
return try BundleSourceConfig(
178178
downloaderConfig: downloaderConfig,
179179
service: service,
180-
resource: resource,
180+
resource: resource ?? "/bundle/\(name)",
181181
signing: resolvedSigning,
182182
persist: persist,
183183
sizeLimitBytes: sizeLimitBytes

Sources/Config/Config.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ extension OPA {
6363

6464
// Some nested structures require cross-config context, so we resolve those parts out here.
6565
self.discovery = try discovery?.resolved(withKeys: keys)
66-
self.bundles = try bundles.mapValues({ try $0.resolved(withKeys: keys) })
66+
self.bundles = Dictionary(
67+
uniqueKeysWithValues: try bundles.map({ try ($0.key, $0.value.resolved(withKeys: keys, name: $0.key)) })
68+
)
6769
self.keys = keys
6870

6971
try self.validate()
@@ -80,7 +82,7 @@ extension OPA {
8082
self.services = services
8183
} catch {
8284
let servicesArray = try container.decodeIfPresent([ServiceConfig].self, forKey: .services) ?? []
83-
var services = [String: ServiceConfig]()
85+
var services = [String: ServiceConfig](minimumCapacity: servicesArray.count)
8486
for (idx, service) in servicesArray.enumerated() {
8587
guard let name = service.name else {
8688
throw OPA.ConfigError(
@@ -111,7 +113,9 @@ extension OPA {
111113

112114
// Some nested structures require cross-config context, so we resolve those parts out here.
113115
self.discovery = try discovery?.resolved(withKeys: keys)
114-
self.bundles = try bundles.mapValues({ try $0.resolved(withKeys: keys) })
116+
self.bundles = Dictionary(
117+
uniqueKeysWithValues: try bundles.map({ try ($0.key, $0.value.resolved(withKeys: keys, name: $0.key)) })
118+
)
115119
self.keys = keys
116120

117121
try self.validate()

Sources/Config/ServiceConfig.swift

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ extension OPA {
4646
self.allowInsecureTLS = allowInsecureTLS
4747
self.responseHeaderTimeoutSeconds = responseHeaderTimeoutSeconds
4848
self.tls = tls
49-
let credentials = credentials
49+
let credentials = credentials ?? .defaultNoAuth
5050
self.type = type
5151

5252
if case .bearer(let plugin) = credentials {
@@ -67,7 +67,7 @@ extension OPA {
6767
self.responseHeaderTimeoutSeconds = try container.decodeIfPresent(
6868
Int64.self, forKey: .responseHeaderTimeoutSeconds)
6969
self.tls = try container.decodeIfPresent(ServerTLSConfig.self, forKey: .tls)
70-
let credentials = try container.decodeIfPresent(Credentials.self, forKey: .credentials)
70+
let credentials = try container.decodeIfPresent(Credentials.self, forKey: .credentials) ?? .defaultNoAuth
7171
self.type = try container.decodeIfPresent(String.self, forKey: .type)
7272

7373
if case .bearer(let plugin) = credentials {
@@ -88,6 +88,7 @@ extension OPA {
8888
/// config keys in this section-- any configuration will appear under the
8989
/// `plugins` section.
9090
public enum Credentials: Codable, Sendable, Equatable {
91+
case defaultNoAuth
9192
case bearer(BearerAuthPlugin)
9293
case oauth2([String: AnyCodable])
9394
case clientTLS(ClientTLSAuthPlugin)
@@ -112,49 +113,62 @@ extension OPA {
112113
// Check if plugin field is present.
113114
if let pluginName = try container.decodeIfPresent(String.self, forKey: .custom) {
114115
self = .custom(pluginName)
115-
} else {
116-
// Fall back to trying each credential type.
117-
let attemptedCredentialTypes: [Credentials?] = [
118-
try? container.decodeIfPresent(BearerAuthPlugin.self, forKey: .bearer).map { .bearer($0) },
119-
try? container.decodeIfPresent([String: AnyCodable].self, forKey: .oauth2).map { .oauth2($0) },
120-
try? container.decodeIfPresent(ClientTLSAuthPlugin.self, forKey: .clientTLS).map {
121-
.clientTLS($0)
122-
},
123-
try? container.decodeIfPresent([String: AnyCodable].self, forKey: .s3Signing).map {
124-
.s3Signing($0)
125-
},
126-
try? container.decodeIfPresent(GCPMetadataAuthPlugin.self, forKey: .gcpMetadata).map {
127-
.gcpMetadata($0)
128-
},
129-
try? container.decodeIfPresent(
130-
AzureManagedIdentitiesAuthPlugin.self, forKey: .azureManagedIdentity
131-
)
132-
.map {
133-
.azureManagedIdentity($0)
134-
},
135-
]
136-
137-
let foundCredentials = attemptedCredentialTypes.compactMap { $0 }
138-
139-
guard foundCredentials.count == 1 else {
140-
throw DecodingError.dataCorrupted(
141-
DecodingError.Context(
142-
codingPath: container.codingPath,
143-
debugDescription: foundCredentials.isEmpty
144-
? "No valid credential type found"
145-
: "Expected exactly one credential type, but found \(foundCredentials.count)"
146-
)
116+
return
117+
}
118+
119+
// Determine which credential keys are actually present in the payload.
120+
let credentialKeys: [CodingKeys] = [
121+
.bearer, .oauth2, .clientTLS, .s3Signing, .gcpMetadata, .azureManagedIdentity,
122+
]
123+
let presentKeys = credentialKeys.filter { container.contains($0) }
124+
125+
guard presentKeys.count <= 1 else {
126+
throw DecodingError.dataCorrupted(
127+
DecodingError.Context(
128+
codingPath: container.codingPath,
129+
debugDescription:
130+
"Expected at most one credential type, but found \(presentKeys.count)"
147131
)
148-
}
132+
)
133+
}
149134

150-
self = foundCredentials[0]
135+
// No credential keys present (e.g. empty `{}`): default to no auth.
136+
guard let key = presentKeys.first else {
137+
self = .defaultNoAuth
138+
return
139+
}
140+
141+
// Exactly one key present — decode it strictly so misconfigurations propagate.
142+
switch key {
143+
case .bearer:
144+
self = .bearer(try container.decode(BearerAuthPlugin.self, forKey: .bearer))
145+
case .oauth2:
146+
self = .oauth2(try container.decode([String: AnyCodable].self, forKey: .oauth2))
147+
case .clientTLS:
148+
self = .clientTLS(try container.decode(ClientTLSAuthPlugin.self, forKey: .clientTLS))
149+
case .s3Signing:
150+
self = .s3Signing(try container.decode([String: AnyCodable].self, forKey: .s3Signing))
151+
case .gcpMetadata:
152+
self = .gcpMetadata(try container.decode(GCPMetadataAuthPlugin.self, forKey: .gcpMetadata))
153+
case .azureManagedIdentity:
154+
self = .azureManagedIdentity(
155+
try container.decode(AzureManagedIdentitiesAuthPlugin.self, forKey: .azureManagedIdentity))
156+
default:
157+
throw DecodingError.dataCorrupted(
158+
DecodingError.Context(
159+
codingPath: container.codingPath,
160+
debugDescription: "Unexpected credential key: \(key.stringValue)"
161+
)
162+
)
151163
}
152164
}
153165

154166
public func encode(to encoder: Encoder) throws {
155167
var container = encoder.container(keyedBy: CodingKeys.self)
156168

157169
switch self {
170+
case .defaultNoAuth:
171+
break
158172
case .bearer(let plugin):
159173
try container.encode(plugin, forKey: .bearer)
160174
case .oauth2(let config):
@@ -175,6 +189,16 @@ extension OPA {
175189

176190
// Validates struct-local constraints.
177191
public func validate() throws {
192+
// Ensure URL is a valid HTTP/HTTPS URL.
193+
guard let scheme = self.url.scheme?.lowercased(),
194+
scheme == "http" || scheme == "https",
195+
self.url.host != nil
196+
else {
197+
throw OPA.ConfigError(
198+
code: .internalError, message: "Expected a valid http/https URL, got: \(self.url)")
199+
}
200+
201+
// For credentials types that need extra context, we validate those here.
178202
switch self.credentials {
179203
case .clientTLS(let plugin):
180204
try plugin.validateWithContext(serviceTLS: self.tls)
Lines changed: 1 addition & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import AST
2-
import Config
32
import Foundation
43
import Rego
54

@@ -8,73 +7,6 @@ extension OPA {
87
/// It is expected that implementations each have to be created
98
/// from an OPA config's service / resource definitions.
109
public protocol BundleLoader {
11-
func load() -> Result<Bundle, any Swift.Error>
12-
}
13-
14-
/// DiskBasedBundleSource abstracts over folder and tarball bundles.
15-
public struct DiskBasedBundleLoader: BundleLoader {
16-
public let name: String
17-
public let fetchURL: URL
18-
19-
public init(services: [String: ServiceConfig], name: String, resource: BundleSourceConfig) throws {
20-
self.name = name
21-
guard let url = URL(string: resource.resource ?? "") else {
22-
throw RuntimeError(
23-
code: .internalError,
24-
message: "Invalid URL for bundle config \(name): \(resource.resource ?? "")"
25-
)
26-
}
27-
// If no bundle service specified to fetch from, make sure we have a file URL to load from disk.
28-
guard !(resource.service.isEmpty && url.scheme != "file") else {
29-
throw RuntimeError(
30-
code: .internalError,
31-
message: "No service config or file:// URL was provided for bundle config \(name)."
32-
)
33-
}
34-
self.fetchURL = url
35-
}
36-
37-
public func load() -> Result<Bundle, any Swift.Error> {
38-
var isDirectory: ObjCBool = false
39-
if FileManager.default.fileExists(atPath: self.fetchURL.path, isDirectory: &isDirectory) {
40-
if isDirectory.boolValue {
41-
// Assert directory not empty before attempting to load it as a bundle.
42-
do {
43-
guard !(try FileManager.default.contentsOfDirectory(atPath: self.fetchURL.path).isEmpty)
44-
else {
45-
throw OPA.Bundle.LoadError.unsupported("Directory was empty")
46-
}
47-
} catch {
48-
return .failure(
49-
RuntimeError(
50-
code: .internalError,
51-
message:
52-
"bundle \(name) failed to load with error: \(error)"
53-
))
54-
}
55-
// Directory not empty.
56-
do {
57-
let bundle = try Bundle.decodeFromDirectory(fromDir: self.fetchURL)
58-
return .success(bundle)
59-
} catch {
60-
return .failure(error)
61-
}
62-
} else {
63-
do {
64-
let bundleData = try Data(contentsOf: self.fetchURL)
65-
let bundle = try Bundle.decodeFromTarball(from: bundleData)
66-
return .success(bundle)
67-
} catch {
68-
return .failure(error)
69-
}
70-
}
71-
}
72-
return .failure(
73-
RuntimeError(
74-
code: .internalError,
75-
message:
76-
"bundle \(name) failed to load. No file or directory found."
77-
))
78-
}
10+
func load() async -> Result<Bundle, any Swift.Error>
7911
}
8012
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Config
2+
import Foundation
3+
import Rego
4+
5+
extension OPA {
6+
/// DiskBasedBundleLoader abstracts over folder and tarball bundles.
7+
public struct DiskBasedBundleLoader: BundleLoader {
8+
public let name: String
9+
public let fetchURL: URL
10+
11+
public init(services: [String: ServiceConfig], name: String, resource: BundleSourceConfig) throws {
12+
self.name = name
13+
guard let url = URL(string: resource.resource ?? "") else {
14+
throw RuntimeError(
15+
code: .internalError,
16+
message: "Invalid URL for bundle config \(name): \(resource.resource ?? "")"
17+
)
18+
}
19+
// If no bundle service specified to fetch from, make sure we have a file URL to load from disk.
20+
guard !(resource.service.isEmpty && url.scheme != "file") else {
21+
throw RuntimeError(
22+
code: .internalError,
23+
message: "No service config or file:// URL was provided for bundle config \(name)."
24+
)
25+
}
26+
self.fetchURL = url
27+
}
28+
29+
// If the resource is a file URL, we can load it.
30+
public static func compatibleWithConfig(resource: BundleSourceConfig) -> Bool {
31+
return (URL(string: resource.resource ?? "")?.scheme == "file")
32+
}
33+
34+
public func load() async -> Result<Bundle, any Swift.Error> {
35+
var isDirectory: ObjCBool = false
36+
if FileManager.default.fileExists(atPath: self.fetchURL.path, isDirectory: &isDirectory) {
37+
if isDirectory.boolValue {
38+
// Assert directory not empty before attempting to load it as a bundle.
39+
do {
40+
guard !(try FileManager.default.contentsOfDirectory(atPath: self.fetchURL.path).isEmpty)
41+
else {
42+
throw OPA.Bundle.LoadError.unsupported("Directory was empty")
43+
}
44+
} catch {
45+
return .failure(
46+
RuntimeError(
47+
code: .internalError,
48+
message:
49+
"bundle \(name) failed to load with error: \(error)"
50+
))
51+
}
52+
// Directory not empty.
53+
do {
54+
let bundle = try Bundle.decodeFromDirectory(fromDir: self.fetchURL)
55+
return .success(bundle)
56+
} catch {
57+
return .failure(error)
58+
}
59+
} else {
60+
do {
61+
let bundleData = try Data(contentsOf: self.fetchURL)
62+
let bundle = try Bundle.decodeFromTarball(from: bundleData)
63+
return .success(bundle)
64+
} catch {
65+
return .failure(error)
66+
}
67+
}
68+
}
69+
return .failure(
70+
RuntimeError(
71+
code: .internalError,
72+
message:
73+
"bundle \(name) failed to load. No file or directory found."
74+
))
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)