Skip to content

Commit d44109d

Browse files
Config: Improve validation and defaults logic (#16)
Config: Add improved validation + defaults + DiscoveryConfig This commit refactors how the config types work together, and how they implement validation. The key insight is that in OPA the `validateAndInjectDefaults` functions are doing one or more of four distinct tasks: - Default values: Handled in Swift `init` / `decode` methods. - Struct-local validation: Handled with a `validate() throws` method. - Validation needing external state from other parts of the config: Handled with custom `validateWithContext(...) throws` methods. - Default values needing external state from other parts of the config: Handled with custom `resolved(...) throws` methods. Now that these new approaches are integrated everywhere, the config types are harder to use incorrectly, and we do as much validation as possible, as early in construction as possible. Signed-off-by: Philip Conrad <philip_conrad@apple.com>
1 parent c1ad68d commit d44109d

18 files changed

Lines changed: 2369 additions & 462 deletions

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ let package = Package(
5555
name: "SwiftOPASDKTests",
5656
dependencies: ["SwiftOPASDK"]
5757
),
58+
.testTarget(
59+
name: "ConfigTests",
60+
dependencies: ["Config"]
61+
),
5862
.testTarget(
5963
name: "RegoExtensionTests",
6064
dependencies: ["SwiftOPASDK"]

Sources/Config/BundleConfig.swift

Lines changed: 98 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import AST
21
import Foundation
32
import Rego
43

@@ -25,6 +24,41 @@ extension OPA {
2524
self.exclude = exclude
2625
}
2726

27+
/// Returns a new config with the provided public keys injected,
28+
/// validating that `keyID` (if set) references an existing key.
29+
///
30+
/// This follows the "resolve" pattern: because `publicKeys` are defined
31+
/// in a separate top-level config section, they aren't available at
32+
/// decode time. The parent config calls this method during its
33+
/// resolution phase to produce a fully-populated instance.
34+
///
35+
/// Ported from Go's `VerificationConfig.ValidateAndInjectDefaults`.
36+
public func resolved(withKeys keys: [String: KeyConfig]) throws -> BundleVerificationConfig {
37+
if !keyID.isEmpty && keys[keyID] == nil {
38+
throw ConfigError(
39+
code: .internalError,
40+
message: "Key ID '\(keyID)' not found"
41+
)
42+
}
43+
return BundleVerificationConfig(
44+
publicKeys: keys,
45+
keyID: keyID,
46+
scope: scope,
47+
exclude: exclude
48+
)
49+
}
50+
51+
/// Validates constraints that require context from the parent `Config` struct.
52+
/// Ported from Go's `VerificationConfig.ValidateAndInjectDefaults`.
53+
public func validateWithContext(keys: [String: KeyConfig]) throws {
54+
guard keyID.isEmpty || keys[keyID] != nil else {
55+
throw ConfigError(
56+
code: .internalError,
57+
message: "Key ID '\(keyID)' not found"
58+
)
59+
}
60+
}
61+
2862
private enum CodingKeys: String, CodingKey {
2963
case publicKeys
3064
case keyID = "keyid"
@@ -47,46 +81,38 @@ extension OPA {
4781

4882
// service is set to an empty string only for file:// resource URLs.
4983
public init(
50-
downloaderConfig: DownloaderConfig = DownloaderConfig(),
51-
service: String,
84+
downloaderConfig: DownloaderConfig? = nil,
85+
service: String = "",
5286
resource: String? = nil,
5387
signing: BundleVerificationConfig? = nil,
5488
persist: Bool? = nil,
5589
sizeLimitBytes: Int64 = 10 * 1024 * 1024 // 10 MB
5690
) throws {
57-
self.downloaderConfig = downloaderConfig
91+
self.downloaderConfig = try downloaderConfig ?? DownloaderConfig()
5892
self.service = service
5993
self.resource = resource
60-
// TODO: Validate file:// URLs on empty service name here too? Might be worth having a "validate()" method?
6194
self.signing = signing
6295
self.persist = persist
6396
self.sizeLimitBytes = sizeLimitBytes
97+
try self.validate()
6498
}
6599

66100
public init(from decoder: Decoder) throws {
67101
let container = try decoder.container(keyedBy: CodingKeys.self)
68102

69103
// Decode DownloaderConfig properties inline
70-
let trigger = try container.decodeIfPresent(PluginTriggerMode.self, forKey: .trigger)
104+
let trigger = try container.decodeIfPresent(TriggerMode.self, forKey: .trigger)
71105
let polling = try container.decodeIfPresent(PollingConfig.self, forKey: .polling)
72-
self.downloaderConfig = DownloaderConfig(trigger: trigger, polling: polling)
106+
self.downloaderConfig = try DownloaderConfig(trigger: trigger, polling: polling)
73107

74108
self.service = try container.decodeIfPresent(String.self, forKey: .service) ?? ""
75109
self.resource = try container.decodeIfPresent(String.self, forKey: .resource)
76-
// Service is only allowed to be unset when resource is a file:// URL.
77-
let isFileURL = self.resource.flatMap(URL.init(string:))?.scheme == "file"
78-
guard !self.service.isEmpty || isFileURL else {
79-
throw DecodingError.dataCorrupted(
80-
DecodingError.Context(
81-
codingPath: container.codingPath,
82-
debugDescription: "No service config or file:// URL was provided for bundle config."
83-
)
84-
)
85-
}
86110
self.signing = try container.decodeIfPresent(BundleVerificationConfig.self, forKey: .signing)
87111
self.persist = try container.decodeIfPresent(Bool.self, forKey: .persist)
88112
// 10 MB default
89113
self.sizeLimitBytes = try container.decodeIfPresent(Int64.self, forKey: .sizeLimitBytes) ?? 10 * 1024 * 1024
114+
115+
try self.validate()
90116
}
91117

92118
public func encode(to encoder: Encoder) throws {
@@ -103,6 +129,61 @@ extension OPA {
103129
try container.encodeIfPresent(sizeLimitBytes, forKey: .sizeLimitBytes)
104130
}
105131

132+
/// Validates struct-local constraints.
133+
public func validate() throws {
134+
let isFileURL = self.resource.flatMap(URL.init(string:))?.scheme == "file"
135+
guard !self.service.isEmpty || isFileURL else {
136+
throw ConfigError(
137+
code: .internalError, message: "No service config or file:// URL was provided for bundle config.")
138+
}
139+
}
140+
141+
/// Validates constraints that require context from the parent `Config` struct.
142+
public func validateWithContext(name: String, services: [String: ServiceConfig], keys: [String: KeyConfig])
143+
throws
144+
{
145+
// Prevent bundle referencing a non-existent service.
146+
guard self.service.isEmpty || services[self.service] != nil else {
147+
throw ConfigError(
148+
code: .internalError,
149+
message:
150+
"Bundle config for '\(name)' references non-existent service: '\(self.service)'"
151+
)
152+
}
153+
// If no service specified, require a file:// URL to load from disk.
154+
guard !self.service.isEmpty || (URL(string: self.resource ?? "")?.scheme == "file") else {
155+
throw ConfigError(
156+
code: .internalError,
157+
message:
158+
"Bundle config for '\(name)' has no service config or file:// URL resource config. \(self.resource ?? "(nil)")"
159+
)
160+
}
161+
162+
try self.signing?.validateWithContext(keys: keys)
163+
}
164+
165+
/// Returns a new config with all context-dependent properties resolved.
166+
///
167+
/// This follows the "resolve" pattern: some properties are defined
168+
/// in separate top-level config sections and aren't available at decode
169+
/// time. The parent config calls this method during its resolution phase
170+
/// to produce a fully-populated instance.
171+
public func resolved(withKeys keys: [String: KeyConfig]) throws -> BundleSourceConfig {
172+
var resolvedSigning = try self.signing?.resolved(withKeys: keys)
173+
if !keys.isEmpty && resolvedSigning == nil {
174+
resolvedSigning = BundleVerificationConfig(publicKeys: keys, keyID: "", scope: "", exclude: [])
175+
}
176+
177+
return try BundleSourceConfig(
178+
downloaderConfig: downloaderConfig,
179+
service: service,
180+
resource: resource,
181+
signing: resolvedSigning,
182+
persist: persist,
183+
sizeLimitBytes: sizeLimitBytes
184+
)
185+
}
186+
106187
private enum CodingKeys: String, CodingKey {
107188
case trigger
108189
case polling

Sources/Config/Config.swift

Lines changed: 55 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ extension OPA {
1313
// public let decisionLogs: DecisionLogsConfig?
1414
// public let status: StatusConfig?
1515
// public let plugins: [String: PluginConfig]
16-
// public let keys: KeysConfig?
16+
public let keys: [String: KeyConfig]
1717
// public let defaultDecision: String?
1818
// public let defaultAuthorizationDecision: String?
1919
// public let caching: CachingConfig?
@@ -28,11 +28,11 @@ extension OPA {
2828
services: [String: ServiceConfig] = [:],
2929
labels: [String: String] = [:],
3030
discovery: DiscoveryConfig? = nil,
31-
bundles: [String: BundleSourceConfig] = [:]
32-
// decisionLogs: DecisionLogsConfig? = nil,
33-
// status: StatusConfig? = nil,
34-
// plugins: [String: PluginConfig] = [:],
35-
// keys: KeysConfig? = nil,
31+
bundles: [String: BundleSourceConfig] = [:],
32+
// decisionLogs: DecisionLogsConfig? = nil,
33+
// status: StatusConfig? = nil,
34+
// plugins: [String: PluginConfig] = [:],
35+
keys: [String: KeyConfig] = [:]
3636
// defaultDecision: String? = nil,
3737
// defaultAuthorizationDecision: String? = nil,
3838
// caching: CachingConfig? = nil,
@@ -42,15 +42,15 @@ extension OPA {
4242
// server: ServerConfig? = nil,
4343
// storage: StorageConfig? = nil,
4444
// extra: [String: AnyCodable]? = nil
45-
) {
45+
) throws {
4646
self.services = services
4747
self.labels = labels
48-
self.discovery = discovery
49-
self.bundles = bundles
48+
let discovery = discovery
49+
let bundles = bundles
5050
// self.decisionLogs = decisionLogs
5151
// self.status = status
5252
// self.plugins = plugins
53-
// self.keys = keys
53+
let keys = keys
5454
// self.defaultDecision = defaultDecision
5555
// self.defaultAuthorizationDecision = defaultAuthorizationDecision
5656
// self.caching = caching
@@ -60,21 +60,45 @@ extension OPA {
6060
// self.server = server
6161
// self.storage = storage
6262
// self.extra = extra
63+
64+
// Some nested structures require cross-config context, so we resolve those parts out here.
65+
self.discovery = try discovery?.resolved(withKeys: keys)
66+
self.bundles = try bundles.mapValues({ try $0.resolved(withKeys: keys) })
67+
self.keys = keys
68+
69+
try self.validate()
6370
}
6471

6572
// MARK: - Decoder
6673

6774
public init(from decoder: Decoder) throws {
6875
let container = try decoder.container(keyedBy: CodingKeys.self)
6976

70-
self.services = try container.decodeIfPresent([String: ServiceConfig].self, forKey: .services) ?? [:]
77+
// Services can be provided as a dictionary or an array with the name fields set.
78+
do {
79+
let services = try container.decodeIfPresent([String: ServiceConfig].self, forKey: .services) ?? [:]
80+
self.services = services
81+
} catch {
82+
let servicesArray = try container.decodeIfPresent([ServiceConfig].self, forKey: .services) ?? []
83+
var services = [String: ServiceConfig]()
84+
for (idx, service) in servicesArray.enumerated() {
85+
guard let name = service.name else {
86+
throw OPA.ConfigError(
87+
code: .internalError,
88+
message: "Missing \"name\" key for service at index \(idx) in services array")
89+
}
90+
services[name] = service
91+
}
92+
self.services = services
93+
}
94+
7195
self.labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
72-
self.discovery = try container.decodeIfPresent(DiscoveryConfig.self, forKey: .discovery)
73-
self.bundles = try container.decodeIfPresent([String: BundleSourceConfig].self, forKey: .bundles) ?? [:]
96+
let discovery = try container.decodeIfPresent(DiscoveryConfig.self, forKey: .discovery)
97+
let bundles = try container.decodeIfPresent([String: BundleSourceConfig].self, forKey: .bundles) ?? [:]
7498
// self.decisionLogs = try container.decodeIfPresent(DecisionLogsConfig.self, forKey: .decisionLogs)
7599
// self.status = try container.decodeIfPresent(StatusConfig.self, forKey: .status)
76100
// self.plugins = try container.decodeIfPresent([String: PluginConfig].self, forKey: .plugins) ?? [:]
77-
// self.keys = try container.decodeIfPresent(KeysConfig.self, forKey: .keys)
101+
let keys = try container.decodeIfPresent([String: KeyConfig].self, forKey: .keys) ?? [:]
78102
// self.defaultDecision = try container.decodeIfPresent(String.self, forKey: .defaultDecision)
79103
// self.defaultAuthorizationDecision = try container.decodeIfPresent(String.self, forKey: .defaultAuthorizationDecision)
80104
// self.caching = try container.decodeIfPresent(CachingConfig.self, forKey: .caching)
@@ -85,33 +109,12 @@ extension OPA {
85109
// self.storage = try container.decodeIfPresent(StorageConfig.self, forKey: .storage)
86110
// self.extra = nil // Extra is not decoded from JSON (matches Go's `json:"-"`)
87111

88-
// Validation for decoded config.
89-
for (name, bundle) in self.bundles {
90-
// Prevent bundle referencing a non-existent service.
91-
guard bundle.service.isEmpty || self.services[bundle.service] != nil else {
92-
throw DecodingError.dataCorrupted(
93-
DecodingError.Context(
94-
codingPath: container.codingPath + [
95-
CodingKeys.bundles, DynamicCodingKey(stringValue: name),
96-
],
97-
debugDescription:
98-
"Bundle config for '\(name)' references non-existent service: '\(bundle.service)'"
99-
)
100-
)
101-
}
102-
// If no service specified, require a file:// URL to load from disk.
103-
guard !bundle.service.isEmpty || (URL(string: bundle.resource ?? "")?.scheme == "file") else {
104-
throw DecodingError.dataCorrupted(
105-
DecodingError.Context(
106-
codingPath: container.codingPath + [
107-
CodingKeys.bundles, DynamicCodingKey(stringValue: name),
108-
],
109-
debugDescription:
110-
"Bundle config for '\(name)' has no service config or file:// URL resource config. \(bundle.resource ?? "(nil)")"
111-
)
112-
)
113-
}
114-
}
112+
// Some nested structures require cross-config context, so we resolve those parts out here.
113+
self.discovery = try discovery?.resolved(withKeys: keys)
114+
self.bundles = try bundles.mapValues({ try $0.resolved(withKeys: keys) })
115+
self.keys = keys
116+
117+
try self.validate()
115118
}
116119

117120
// MARK: - Encoder
@@ -126,7 +129,7 @@ extension OPA {
126129
// try container.encodeIfPresent(decisionLogs, forKey: .decisionLogs)
127130
// try container.encodeIfPresent(status, forKey: .status)
128131
// try container.encodeIfPresent(plugins.isEmpty ? nil : plugins, forKey: .plugins)
129-
// try container.encodeIfPresent(keys, forKey: .keys)
132+
try container.encodeIfPresent(keys, forKey: .keys)
130133
// try container.encodeIfPresent(defaultDecision, forKey: .defaultDecision)
131134
// try container.encodeIfPresent(defaultAuthorizationDecision, forKey: .defaultAuthorizationDecision)
132135
// try container.encodeIfPresent(caching, forKey: .caching)
@@ -138,6 +141,16 @@ extension OPA {
138141
// Extra is not encoded to JSON (matches Go's `json:"-"`)
139142
}
140143

144+
public func validate() throws {
145+
for (name, bundleConfig) in self.bundles {
146+
try bundleConfig.validateWithContext(name: name, services: self.services, keys: self.keys)
147+
}
148+
149+
for (id, keyConfig) in self.keys {
150+
try keyConfig.validateWithContext(id: id)
151+
}
152+
}
153+
141154
private enum CodingKeys: String, CodingKey {
142155
case services
143156
case labels

0 commit comments

Comments
 (0)