Skip to content

Commit

Permalink
Add AWS SecretsManager Support (#6)
Browse files Browse the repository at this point in the history
* Add targets

* Add protocol for SecretsManager responses

* Add types, helpers

* Implement Secrets

* Update for new target

* Avoid duplicated file names

* Add documentation catalog for Secrets

* Minor fixes

* Additional protocol conformances

* Add factory
  • Loading branch information
mgacy authored Dec 27, 2023
1 parent 5a75acd commit a8b3c80
Show file tree
Hide file tree
Showing 9 changed files with 286 additions and 5 deletions.
2 changes: 1 addition & 1 deletion .spi.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [EmailSender, PersPersistenceistence]
- documentation_targets: [EmailSender, PersPersistenceistence, Secrets]
14 changes: 13 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -26,13 +27,24 @@ 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"]
),
.testTarget(
name: "PersistenceTests",
dependencies: ["Persistence"]
),
.testTarget(
name: "SecretsTests",
dependencies: ["Secrets"]
)
]
)
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Where `<product>` is one of the following:

- `EmailSender`
- `Persistence`
- `Secrets`

## ⚙️ Usage

Expand Down Expand Up @@ -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"
])
```
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Extensions.swift
// AWSSES+Utils.swift
// AWSExtras
//
// Created by Mathew Gacy on 12/8/23.
Expand Down Expand Up @@ -29,4 +29,3 @@ extension SESClientTypes.Body {
self.init(html: html, text: text)
}
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Extensions.swift
// AWSDynamoDB+Utils.swift
// AWSExtras
//
// Created by Mathew Gacy on 12/8/23.
Expand Down
11 changes: 11 additions & 0 deletions Sources/Secrets/Documentation.docc/Secrets.md
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions Sources/Secrets/SecretValue.swift
Original file line number Diff line number Diff line change
@@ -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 {}
176 changes: 176 additions & 0 deletions Sources/Secrets/Secrets.swift
Original file line number Diff line number Diff line change
@@ -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<V: SecretValue>(_ 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 ) })
}
}
16 changes: 16 additions & 0 deletions Tests/SecretsTests/SecretsTests.swift
Original file line number Diff line number Diff line change
@@ -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 {
// ...
}
}

0 comments on commit a8b3c80

Please sign in to comment.