diff --git a/.spi.yml b/.spi.yml index 8c18664..b8f907a 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [EmailSender, PersPersistenceistence] + - documentation_targets: [EmailSender, PersPersistenceistence, Secrets] diff --git a/Package.swift b/Package.swift index cb2addf..2fbe661 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,8 @@ let package = Package( platforms: [.macOS(.v12)], products: [ .library(name: "EmailSender", targets: ["EmailSender"]), - .library(name: "Persistence", targets: ["Persistence"]) + .library(name: "Persistence", targets: ["Persistence"]), + .library(name: "Secrets", targets: ["Secrets"]) ], dependencies: [ .package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "0.19.0") @@ -26,6 +27,13 @@ let package = Package( .product(name: "AWSDynamoDB", package: "aws-sdk-swift") ] ), + .target( + name: "Secrets", + dependencies: [ + .product(name: "AWSSecretsManager", package: "aws-sdk-swift") + ] + ), + // MARK: - Tests .testTarget( name: "EmailSenderTests", dependencies: ["EmailSender"] @@ -33,6 +41,10 @@ let package = Package( .testTarget( name: "PersistenceTests", dependencies: ["Persistence"] + ), + .testTarget( + name: "SecretsTests", + dependencies: ["Secrets"] ) ] ) diff --git a/README.md b/README.md index 4cb2abf..8ac7090 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Where `` is one of the following: - `EmailSender` - `Persistence` +- `Secrets` ## ⚙️ Usage @@ -93,3 +94,32 @@ Persist a model instance: let model = MyModel(name: "foo", value: 42) try await persistence.put(model) ``` + +### 🗝️ Secrets + +Initialize `Secrets` with a region: + +```swift +let secrets = Secrets.live(region: "us-east-1") +``` + +Retrieve a secret string by its id: + +```swift +let secret = try await secrets.string("my-secret-id") +``` + +Retrieve secret data by its id: + +```swift +let secret = try await secrets.data("my-secret-id") +``` + +Retrieve multiple secrets: + +```swift +let secrets = try await secrets.batch([ + "my-secret-id", + "my-other-secret-id" +]) +``` diff --git a/Sources/EmailSender/Extensions.swift b/Sources/EmailSender/AWSSES+Utils.swift similarity index 96% rename from Sources/EmailSender/Extensions.swift rename to Sources/EmailSender/AWSSES+Utils.swift index 5562528..397590c 100644 --- a/Sources/EmailSender/Extensions.swift +++ b/Sources/EmailSender/AWSSES+Utils.swift @@ -1,5 +1,5 @@ // -// Extensions.swift +// AWSSES+Utils.swift // AWSExtras // // Created by Mathew Gacy on 12/8/23. @@ -29,4 +29,3 @@ extension SESClientTypes.Body { self.init(html: html, text: text) } } - diff --git a/Sources/Persistence/Extensions.swift b/Sources/Persistence/AWSDynamoDB+Utils.swift similarity index 96% rename from Sources/Persistence/Extensions.swift rename to Sources/Persistence/AWSDynamoDB+Utils.swift index 6886362..9624824 100644 --- a/Sources/Persistence/Extensions.swift +++ b/Sources/Persistence/AWSDynamoDB+Utils.swift @@ -1,5 +1,5 @@ // -// Extensions.swift +// AWSDynamoDB+Utils.swift // AWSExtras // // Created by Mathew Gacy on 12/8/23. diff --git a/Sources/Secrets/Documentation.docc/Secrets.md b/Sources/Secrets/Documentation.docc/Secrets.md new file mode 100644 index 0000000..980bb0f --- /dev/null +++ b/Sources/Secrets/Documentation.docc/Secrets.md @@ -0,0 +1,11 @@ +# ``Secrets`` + +Retrieve secrets from AWS Secrets Manager. + +## Overview + +Secrets provides a wrapper around AWS Secrets Manager to improve testability. + +## Topics + +### Essentials diff --git a/Sources/Secrets/SecretValue.swift b/Sources/Secrets/SecretValue.swift new file mode 100644 index 0000000..415cdf3 --- /dev/null +++ b/Sources/Secrets/SecretValue.swift @@ -0,0 +1,37 @@ +// +// SecretValue.swift +// AWSExtras +// +// Created by Mathew Gacy on 12/22/23. +// + +import AWSSecretsManager +import Foundation + +/// A class of types providing secrets. +protocol SecretValue: Equatable { + /// The ARN of the secret. + var arn: String? { get } + + /// The friendly name of the secret. + var name: String? { get } + + /// The decrypted secret value, if the secret value was originally provided as binary data in + /// the form of a byte array. + /// + /// If the secret was created by using the Secrets Manager console, or if the secret value was + /// originally provided as a string, then this field is omitted. The secret value appears in + /// `SecretString` instead. + var secretBinary: Data? { get } + + /// The decrypted secret value, if the secret value was originally provided as a string or + /// through the Secrets Manager console. + /// + /// If this secret was created by using the console, then Secrets Manager stores the information + /// as a JSON structure of key/value pairs. + var secretString: String? { get } +} + +extension GetSecretValueOutput: SecretValue {} + +extension SecretsManagerClientTypes.SecretValueEntry: SecretValue {} diff --git a/Sources/Secrets/Secrets.swift b/Sources/Secrets/Secrets.swift new file mode 100644 index 0000000..6a9e41e --- /dev/null +++ b/Sources/Secrets/Secrets.swift @@ -0,0 +1,176 @@ +// +// Secrets.swift +// AWSExtras +// +// Created by Mathew Gacy on 12/22/23. +// + +import AWSSecretsManager +import Foundation + +/// A secret stored by AWS Secrets Manager. +public struct Secret: Equatable, Sendable { + + /// A secret stored by AWS Secrets Manager. + public enum Value: Equatable, Sendable { + /// The decrypted secret value, if the secret value was originally provided as binary data in + /// the form of a byte array. + case binary(Data) + /// The decrypted secret value, if the secret value was originally provided as a string or + /// through the Secrets Manager console. + /// + /// If this secret was created by using the console, then Secrets Manager stores the information + /// as a JSON structure of key/value pairs. + case string(String) + } + + /// The ARN of the secret. + var arn: String + + /// The friendly name of the secret. + var name: String + + /// The decrypted secret value. + var value: Value + + /// Creates a new instance. + /// + /// - Parameters: + /// - arn: The ARN of the secret. + /// - name: The friendly name of the secret. + /// - value: The decrypted secret value. + public init(arn: String, name: String, value: Value) { + self.arn = arn + self.name = name + self.value = value + } +} + +extension Secret { + /// Creates a new instance. + /// + /// - Parameter secretValue: A secret value. + init(_ secretValue: V) throws { + guard let arn = secretValue.arn, let name = secretValue.name else { + throw SecretsError.missingData + } + + self.arn = arn + self.name = name + if let string = secretValue.secretString { + self.value = .string(string) + } else if let data = secretValue.secretBinary { + self.value = .binary(data) + } else { + throw SecretsError.missingData + } + } +} + +/// An error that can be thrown when retrieving secrets. +public enum SecretsError: LocalizedError { + /// The secret is missing expected data. + case missingData + /// The decrypted secret is of an unexpected type. + case wrongSecretType + + /// A localized message describing what error occurred. + public var errorDescription: String? { + switch self { + case .missingData: + return "The secret value is missing expected data." + case .wrongSecretType: + return "The decrypted secret is of an unexpected type." + } + } +} + +extension Optional { + /// Convienence method to `throw` if an optional type has a `nil` value. + /// + /// - Parameter error: The error to throw. + /// - Returns: The unwrapped value. + func unwrap(or error: @autoclosure () -> LocalizedError) throws -> Wrapped { + switch self { + case .some(let wrapped): return wrapped + case .none: throw error() + } + } +} + +/// A type that retrieves secrets. +public struct Secrets: Sendable { + /// The ARN or name of the secret to retrieve. + public typealias ID = String + + /// A closure returning the secret string for the given identifier. + var string: @Sendable (ID) async throws -> String + + /// A closure returning the secret data for the given identifier. + var data: @Sendable (ID) async throws -> Data + + /// A closure returning secrets for the given identifiers. + var batch: @Sendable ([ID]) async throws -> [Secret]? + + public init( + string: @escaping @Sendable (Secrets.ID) async throws -> String, + data: @escaping @Sendable (Secrets.ID) async throws -> Data, + batch: @escaping @Sendable ([Secrets.ID]) async throws -> [Secret]? + ) { + self.string = string + self.data = data + self.batch = batch + } +} + +public extension Secrets { + /// Returns a live implementation. + /// + /// - Parameter region: The AWS region of the secrets manager. + /// - Returns: A live instance. + static func live(region: String) throws -> Self { + let client = try SecretsManagerClient(region: region) + return Secrets( + string: { id in + try await client.getSecretValue(input: GetSecretValueInput(secretId: id)) + .secretString + .unwrap(or: SecretsError.wrongSecretType) + }, + data: { id in + try await client.getSecretValue(input: GetSecretValueInput(secretId: id)) + .secretBinary + .unwrap(or: SecretsError.wrongSecretType) + }, + batch: { secretIDs in + let batchInput = BatchGetSecretValueInput(secretIdList: secretIDs) + return try await client.batchGetSecretValue(input: batchInput) + .secretValues? + .map { try Secret($0) } + }) + } +} + +/// A type that creates ``Secrets`` instances. +public struct SecretsFactory: Sendable { + /// The region where the secrets manager is located. + public typealias Region = String + + /// A closure that creates and returns a ``Secrets`` instance. + public var make: @Sendable (Region) throws -> Secrets + + /// Creates an instance. + /// + /// - Parameter make: A closure returning a ``Secrets`` instance. + public init( + make: @escaping @Sendable (SecretsFactory.Region) throws -> Secrets + ) { + self.make = make + } +} + +public extension SecretsFactory { + /// Returns a live implementation. + static func live() -> Self { + .init(make: { try Secrets.live(region: $0 ) }) + } +} diff --git a/Tests/SecretsTests/SecretsTests.swift b/Tests/SecretsTests/SecretsTests.swift new file mode 100644 index 0000000..8b13e4e --- /dev/null +++ b/Tests/SecretsTests/SecretsTests.swift @@ -0,0 +1,16 @@ +// +// SecretsTests.swift +// AWSExtras +// +// Created by Mathew Gacy on 12/22/23. +// + +@testable import Secrets +import Foundation +import XCTest + +final class SecretsTests: XCTestCase { + func testSecrets() async throws { + // ... + } +}