Skip to content

Commit 52c6392

Browse files
authored
Add support for S3 arn based access points (#627)
* Add parsing of ARNs as S3 buckets * Add support for using ARN based S3 access points * Explicitly set service config instead of using `.with` as endpoints in original service are wrong point to `s3`. * Fix 5.x compile error * Changes requested in PR Add comment to ARN type, removed commented out code * More comments
1 parent 11453ca commit 52c6392

File tree

7 files changed

+343
-78
lines changed

7 files changed

+343
-78
lines changed

Sources/SotoCore/AWSClient.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ public final class AWSClient: Sendable {
186186
case waiterFailed
187187
case waiterTimeout
188188
case failedToAccessPayload
189+
case invalidARN
189190
}
190191

191192
let error: Error
@@ -202,6 +203,8 @@ public final class AWSClient: Sendable {
202203
public static var waiterTimeout: ClientError { .init(error: .waiterTimeout) }
203204
/// Failed to access payload while building request
204205
public static var failedToAccessPayload: ClientError { .init(error: .failedToAccessPayload) }
206+
/// ARN provided to client is invalid
207+
public static var invalidARN: ClientError { .init(error: .invalidARN) }
205208
}
206209

207210
/// Additional options
@@ -550,6 +553,8 @@ extension AWSClient.ClientError: CustomStringConvertible {
550553
return "Waiter failed to complete in time allocated"
551554
case .failedToAccessPayload:
552555
return "Failed to access payload while building request for AWS"
556+
case .invalidARN:
557+
return "ARN provided to the client was invalid"
553558
}
554559
}
555560
}

Sources/SotoCore/AWSServiceConfig.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,10 @@ public final class AWSServiceConfig {
298298
service: AWSServiceConfig,
299299
with patch: Patch
300300
) {
301-
let region = patch.region ?? service.region
302-
let options = patch.options ?? service.options
301+
self.region = patch.region ?? service.region
302+
self.options = patch.options ?? service.options
303+
self.serviceIdentifier = service.serviceIdentifier
304+
self.signingName = service.signingName
303305

304306
if let endpoint = patch.endpoint {
305307
self.endpoint = endpoint
@@ -308,23 +310,19 @@ public final class AWSServiceConfig {
308310
patch.endpoint
309311
?? Self.getEndpoint(
310312
endpoint: service.providedEndpoint,
311-
region: region,
312-
serviceIdentifier: service.serviceIdentifier,
313-
options: options,
313+
region: self.region,
314+
serviceIdentifier: self.serviceIdentifier,
315+
options: self.options,
314316
serviceEndpoints: service.serviceEndpoints,
315317
partitionEndpoints: service.partitionEndpoints,
316318
variantEndpoints: service.variantEndpoints
317319
)
318320
} else {
319321
self.endpoint = service.endpoint
320322
}
321-
self.region = region
322-
self.options = options
323323

324324
self.amzTarget = service.amzTarget
325325
self.serviceName = service.serviceName
326-
self.serviceIdentifier = service.serviceIdentifier
327-
self.signingName = service.signingName
328326
self.serviceProtocol = service.serviceProtocol
329327
self.apiVersion = service.apiVersion
330328
self.providedEndpoint = service.providedEndpoint

Sources/SotoCore/Doc/ARN.swift

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Soto for AWS open source project
4+
//
5+
// Copyright (c) 2017-2024 the Soto project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Soto project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// Amazon Resource Name (ARN). A unique identifier assigned to AWS resource
16+
///
17+
/// Comes in one of the following forms
18+
/// - arn:partition:service:region:account-id:resource-id
19+
/// - arn:partition:service:region:account-id:resource-type/resource-id
20+
/// - arn:partition:service:region:account-id:resource-type:resource-id
21+
public struct ARN {
22+
public init?<S: StringProtocol>(string: S) where S.SubSequence == Substring {
23+
let split = string.split(separator: ":", omittingEmptySubsequences: false)
24+
guard split.count >= 6 else { return nil }
25+
guard split[0] == "arn" else { return nil }
26+
guard let partition = AWSPartition(rawValue: String(split[1])) else { return nil }
27+
self.partition = partition
28+
self.service = split[2]
29+
self.region = split[3].count > 0 ? Region(rawValue: String(split[3])) : nil
30+
if let region {
31+
guard region.partition == self.partition else { return nil }
32+
}
33+
self.accountId = split[4].count > 0 ? split[4] : nil
34+
guard self.accountId?.first(where: { !$0.isNumber }) == nil else { return nil }
35+
if split.count == 6 {
36+
let resourceSplit = split[5].split(separator: "/", maxSplits: 1)
37+
if resourceSplit.count == 1 {
38+
self.resourceType = nil
39+
self.resourceId = resourceSplit[0]
40+
} else {
41+
self.resourceType = resourceSplit[0]
42+
self.resourceId = resourceSplit[1]
43+
}
44+
} else if split.count == 7 {
45+
self.resourceType = split[5]
46+
self.resourceId = split[6]
47+
} else {
48+
return nil
49+
}
50+
}
51+
52+
public let partition: AWSPartition
53+
public let service: Substring
54+
public let region: Region?
55+
public let accountId: Substring?
56+
public let resourceId: Substring
57+
public let resourceType: Substring?
58+
}

Sources/SotoCore/Middleware/Middleware.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import Logging
1616

1717
/// Context object sent to `AWSMiddlewareProtocol` `handle` functions
18-
public struct AWSMiddlewareContext {
18+
public struct AWSMiddlewareContext: Sendable {
1919
public var operation: String
2020
public var serviceConfig: AWSServiceConfig
2121
public var logger: Logger

Sources/SotoCore/Middleware/Middleware/S3Middleware.swift

Lines changed: 147 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -37,87 +37,173 @@ internal import SotoXML
3737
/// - Creates error body for notFound responses to HEAD requests
3838
public struct S3Middleware: AWSMiddlewareProtocol {
3939
public func handle(_ request: AWSHTTPRequest, context: AWSMiddlewareContext, next: AWSMiddlewareNextHandler) async throws -> AWSHTTPResponse {
40-
var request = request
4140

42-
self.virtualAddressFixup(request: &request, context: context)
43-
self.createBucketFixup(request: &request, context: context)
44-
if !context.serviceConfig.options.contains(.s3Disable100Continue) {
45-
self.expect100Continue(request: &request)
46-
}
41+
try await self.handleVirtualAddressFixup(request, context: context) { request, context in
42+
var request = request
43+
self.createBucketFixup(request: &request, context: context)
44+
if !context.serviceConfig.options.contains(.s3Disable100Continue) {
45+
self.expect100Continue(request: &request)
46+
}
4747

48-
do {
49-
var response = try await next(request, context)
50-
if context.operation == "GetBucketLocation" {
51-
self.getBucketLocationResponseFixup(response: &response)
48+
do {
49+
var response = try await next(request, context)
50+
if context.operation == "GetBucketLocation" {
51+
self.getBucketLocationResponseFixup(response: &response)
52+
}
53+
return response
54+
} catch let error as AWSRawError {
55+
let newError = self.fixupRawError(error: error, context: context)
56+
throw newError
5257
}
53-
return response
54-
} catch let error as AWSRawError {
55-
let newError = self.fixupRawError(error: error, context: context)
56-
throw newError
5758
}
5859
}
5960

6061
public init() {}
6162

62-
func virtualAddressFixup(request: inout AWSHTTPRequest, context: AWSMiddlewareContext) {
63+
func handleVirtualAddressFixup(
64+
_ request: AWSHTTPRequest,
65+
context: AWSMiddlewareContext,
66+
next: AWSMiddlewareNextHandler
67+
) async throws -> AWSHTTPResponse {
68+
if request.url.path.hasPrefix("/arn:") {
69+
return try await handleARNBucket(request, context: context, next: next)
70+
}
6371
/// process URL into form ${bucket}.s3.amazon.com
64-
let paths = request.url.path.split(separator: "/", omittingEmptySubsequences: true)
65-
if paths.count > 0 {
66-
guard var host = request.url.host else { return }
67-
if let port = request.url.port {
68-
host = "\(host):\(port)"
72+
let paths = request.url.path.split(separator: "/", maxSplits: 2, omittingEmptySubsequences: false).dropFirst()
73+
guard let bucket = paths.first, var host = request.url.host else { return try await next(request, context) }
74+
75+
if let port = request.url.port {
76+
host = "\(host):\(port)"
77+
}
78+
var urlPath: String
79+
var urlHost: String
80+
let isAmazonUrl = host.hasSuffix("amazonaws.com")
81+
82+
var hostComponents = host.split(separator: ".")
83+
if isAmazonUrl, context.serviceConfig.options.contains(.s3UseTransferAcceleratedEndpoint) {
84+
if let s3Index = hostComponents.firstIndex(where: { $0 == "s3" }) {
85+
var s3 = "s3"
86+
s3 += "-accelerate"
87+
// assume next host component is region
88+
let regionIndex = s3Index + 1
89+
hostComponents.remove(at: regionIndex)
90+
hostComponents[s3Index] = Substring(s3)
91+
host = hostComponents.joined(separator: ".")
6992
}
70-
let bucket = paths[0]
71-
var urlPath: String
72-
var urlHost: String
73-
let isAmazonUrl = host.hasSuffix("amazonaws.com")
74-
75-
var hostComponents = host.split(separator: ".")
76-
if isAmazonUrl, context.serviceConfig.options.contains(.s3UseTransferAcceleratedEndpoint) {
77-
if let s3Index = hostComponents.firstIndex(where: { $0 == "s3" }) {
78-
var s3 = "s3"
79-
s3 += "-accelerate"
80-
// assume next host component is region
81-
let regionIndex = s3Index + 1
82-
hostComponents.remove(at: regionIndex)
83-
hostComponents[s3Index] = Substring(s3)
84-
host = hostComponents.joined(separator: ".")
85-
}
93+
}
94+
95+
// Is bucket an ARN
96+
if bucket.hasPrefix("arn:") {
97+
guard let arn = ARN(string: bucket),
98+
let resourceType = arn.resourceType,
99+
let region = arn.region,
100+
let accountId = arn.accountId
101+
else {
102+
throw AWSClient.ClientError.invalidARN
103+
}
104+
guard resourceType == "accesspoint", arn.service == "s3-object-lambda" || arn.service == "s3-outposts" else {
105+
throw AWSClient.ClientError.invalidARN
86106
}
107+
urlPath = "/"
108+
// https://tutorial-object-lambda-accesspoint-123456789012.s3-object-lambda.us-west-2.amazonaws.com:443
109+
urlHost = "\(arn.resourceId)-\(resourceType)-\(accountId).\(arn.service).\(region).amazonaws.com"
87110

88111
// if host name contains amazonaws.com and bucket name doesn't contain a period do virtual address look up
89-
if isAmazonUrl || context.serviceConfig.options.contains(.s3ForceVirtualHost), !bucket.contains(".") {
90-
let pathsWithoutBucket = paths.dropFirst() // bucket
91-
urlPath = pathsWithoutBucket.joined(separator: "/")
92-
93-
if hostComponents.first == bucket {
94-
// Bucket name is part of host. No need to append bucket
95-
urlHost = host
96-
} else {
97-
urlHost = "\(bucket).\(host)"
98-
}
99-
} else {
100-
urlPath = paths.joined(separator: "/")
112+
} else if isAmazonUrl || context.serviceConfig.options.contains(.s3ForceVirtualHost), !bucket.contains(".") {
113+
let pathsWithoutBucket = paths.dropFirst() // bucket
114+
urlPath = pathsWithoutBucket.first.flatMap { String($0) } ?? ""
115+
116+
if hostComponents.first == bucket {
117+
// Bucket name is part of host. No need to append bucket
101118
urlHost = host
119+
} else {
120+
urlHost = "\(bucket).\(host)"
102121
}
103-
// add trailing "/" back if it was present, no need to check for single slash path here
104-
if request.url.pathWithSlash.hasSuffix("/") {
105-
urlPath += "/"
106-
}
107-
// add percent encoding back into path as converting from URL to String has removed it
108-
let percentEncodedUrlPath = Self.urlEncodePath(urlPath)
109-
var urlString = "\(request.url.scheme ?? "https")://\(urlHost)/\(percentEncodedUrlPath)"
110-
if let query = request.url.query {
111-
urlString += "?\(query)"
112-
}
113-
request.url = URL(string: urlString)!
122+
} else {
123+
urlPath = paths.joined(separator: "/")
124+
urlHost = host
125+
}
126+
let request = Self.updateRequestURL(request, host: urlHost, path: urlPath)
127+
return try await next(request, context)
128+
}
129+
130+
/// Handle bucket names in the form of an ARN
131+
/// - Parameters:
132+
/// - request: request
133+
/// - context: request context
134+
/// - next: next handler
135+
/// - Returns: returns response from next handler
136+
func handleARNBucket(
137+
_ request: AWSHTTPRequest,
138+
context: AWSMiddlewareContext,
139+
next: AWSMiddlewareNextHandler
140+
) async throws -> AWSHTTPResponse {
141+
guard let arn = ARN(string: request.url.path.dropFirst()),
142+
let resourceType = arn.resourceType,
143+
let accountId = arn.accountId
144+
else {
145+
throw AWSClient.ClientError.invalidARN
146+
}
147+
let region = arn.region ?? context.serviceConfig.region
148+
guard resourceType == "accesspoint", arn.service == "s3-object-lambda" || arn.service == "s3-outposts" || arn.service == "s3" else {
149+
throw AWSClient.ClientError.invalidARN
114150
}
151+
152+
// extract bucket and path from ARN
153+
let resourceIDSplit = arn.resourceId.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
154+
guard let bucket = resourceIDSplit.first else { throw AWSClient.ClientError.invalidARN }
155+
let path = String(resourceIDSplit.dropFirst().first ?? "")
156+
let service = String(arn.service)
157+
let serviceIdentifier = service != "s3" ? service : "s3-accesspoint"
158+
let urlHost = "\(bucket)-\(accountId).\(serviceIdentifier).\(region).amazonaws.com"
159+
let request = Self.updateRequestURL(request, host: urlHost, path: path)
160+
161+
var context = context
162+
context.serviceConfig = AWSServiceConfig(
163+
region: region,
164+
partition: region.partition,
165+
serviceName: "S3",
166+
serviceIdentifier: serviceIdentifier,
167+
signingName: service,
168+
serviceProtocol: context.serviceConfig.serviceProtocol,
169+
apiVersion: context.serviceConfig.apiVersion,
170+
errorType: context.serviceConfig.errorType,
171+
xmlNamespace: context.serviceConfig.xmlNamespace,
172+
middleware: context.serviceConfig.middleware,
173+
timeout: context.serviceConfig.timeout,
174+
byteBufferAllocator: context.serviceConfig.byteBufferAllocator,
175+
options: context.serviceConfig.options
176+
)
177+
return try await next(request, context)
178+
}
179+
180+
/// Update request with new host and path
181+
/// - Parameters:
182+
/// - request: request
183+
/// - host: new host name
184+
/// - path: new path
185+
/// - Returns: new request
186+
static func updateRequestURL(_ request: AWSHTTPRequest, host: some StringProtocol, path: String) -> AWSHTTPRequest {
187+
var path = path
188+
// add trailing "/" back if it was present, no need to check for single slash path here
189+
if request.url.pathWithSlash.hasSuffix("/") {
190+
path += "/"
191+
}
192+
// add percent encoding back into path as converting from URL to String has removed it
193+
let percentEncodedUrlPath = Self.urlEncodePath(path)
194+
var urlString = "\(request.url.scheme ?? "https")://\(host)/\(percentEncodedUrlPath)"
195+
if let query = request.url.query {
196+
urlString += "?\(query)"
197+
}
198+
var request = request
199+
request.url = URL(string: urlString)!
200+
return request
115201
}
116202

117203
static let s3PathAllowedCharacters = CharacterSet.urlPathAllowed.subtracting(.init(charactersIn: "+@()&$=:,'!*"))
118204
/// percent encode path value.
119-
private static func urlEncodePath(_ value: String) -> String {
120-
value.addingPercentEncoding(withAllowedCharacters: Self.s3PathAllowedCharacters) ?? value
205+
private static func urlEncodePath(_ value: some StringProtocol) -> String {
206+
value.addingPercentEncoding(withAllowedCharacters: Self.s3PathAllowedCharacters) ?? String(value)
121207
}
122208

123209
func createBucketFixup(request: inout AWSHTTPRequest, context: AWSMiddlewareContext) {

0 commit comments

Comments
 (0)