SwiftでAWSのAsumeRoleでS3にアクセス

この記事は、未来の自分への記録になります。他の人へわかりやすく解説するものではありませんが、参考になれば幸いです。さて、普段のアプリはお絵描きアプリなどクライアント側で完結しているものが、大半でサーバーにアクセスの必要があっても、URLなどでAPIがすで決まっていていて、それを使うだけのアプリばかりやっていました。今回、サーバー側も手をださないといけなくなり、泣きそうになりながら試行錯誤をつづけています。AWSのAssmeRowは本当に苦労しているので、自分なりのメモを残しておきたいとおもいます。

今回は、不特定多数のユーザーがコンテンツを取得する為にAWSのS3からコンテンツをダウンロードしてえつらんするようなシナリオを想定しています。

## 前提条件

AWSでルートアカウントを所持し、AWS CLI を root ユーザーでconfigureしてある事、AWSコンソールからrootでログインできる事を前提とします。AWS CLIのインストール有無は以下で確認できます。

```sh
$ aws --version
aws-cli/2.15.38 Python/3.11.8 Darwin/23.5.0 exe/x86_64 prompt/off
```

インストールされていない場合は以下のURLから、インストールそしてConfigureをしてください。

https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html

## Hello App

今回は検証用の環境を構築する為、AWS内に後で分別の難しくなるようなリソースが沢山できる事を防ぐ為、「Hello」を使用するものとします。

### Create User

AWSコンソール

`IAM > Users` から　`Create User` を選びます。アプリ自体がユーザーになる事を想定しています。パミッションなどは与えず、このままではアクセスできるリソースが全くないユーザを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/b277fe1e-1d88-386f-1662-bbd880f02bb8.png)

* `Add user to group`を選ぶ
* `Create User`を選ぶ

Userができたら確認しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/7eb46c02-2d89-01fb-9de9-95f0da911e63.png)

## Create Bucket

S3のBucketを作成します。bucket名は自分だけでなく、AWS全体でユーニークの名前なので、`hello-bucket`とか単純な名前は先約があるので、少し苦労が必要です。今回は`hello-app-bucket`の名前で取れました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/c2e0a1ff-4fd3-caa4-0004-286e558be062.png)

Bucketが作成できたら。確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/799c6a1e-500c-d4e7-e474-98b564ccaff0.png)

## Role を作成

次にRoleを作成します。`IAM > Roles` で`Create Role`を選びます。そして`AWS account`を選びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/59609a81-c14b-b684-7dbe-a47fda5a6c8d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/822d729a-0353-6b0a-b83e-d7d8b411f2a5.png)

## User に Role を Attcach

`User > Permissions > Add permissions`から`Create inline policy`を選びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/51aac9ea-95e7-732b-584b-1e53d92ee918.png)

![Screenshot 2024-06-22 at 7.06.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/98515963-7fd3-6b09-31be-b7b02a35c0f2.png)

```json
{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Effect": "Allow",
			"Action": [
				"sts:AssumeRole"
			],
			"Resource": "arn:aws:iam::566372147352:role/hello-role"
		},
		{
			"Effect": "Allow",
			"Action": [
				"s3:*"
			],
			"Resource": [
			    "arn:aws:s3:::hello-app-bucket",
			    "arn:aws:s3:::hello-app-bucket/*"
			 ]
		},
		{
		    "Effect": "Allow",
			"Action": "s3:ListAllMyBuckets",
			"Resource": "arn:aws:s3:::hello-app-bucket"
		}
	]
}
```

`hello-policy`と命名する事にします。

![Screenshot 2024-06-22 at 13.54.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/3887636b-64ba-5615-d90e-800395274ae8.png)

`hello-policy`が追加されている事を確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/532e38d9-fb1b-d936-40a5-6c14f2dac7d0.png)

ちなみに、`inline`で付与した`hello-policy`は`IAM > Access management > Policies`の一覧にないので、自分は、作った筈のPolicyが見つからなくて焦りました。

## 簡易確認

AssumeRoleのアクセス権を軽くテストしてみたいと思います。`IAM > Users > Console sign-in`から、`Enable console access`を選びます。

![Screenshot 2024-06-22 at 14.04.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/c0e53d4a-73f2-280c-11dd-8bc25cdffe75.png)

パスワードを設定します。今回は`Autogenerated password`を選びました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/6d54e9ff-9ec1-15e9-fd51-be0a786ff6ea.png)

その場合、passwordを忘れずに保存します。`Console sign-in link`もメモしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/4985a5e5-b0ee-6835-7c77-6e4253f94524.png)

ブラウザから先ほどの`Console sign-in link`をたどり、ログインします。同じブラウザだと、`root`ユーザーでログイン状態である為、自分はAWS Consoleを扱うブラウザとは異なるブラウザで`hello-user`のログインを行いました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/b07abaae-9093-5e9a-fb7e-ae310d72c799.png)

右上のログインユーザーが`hello-user`になっている事を確認します。

![Screenshot 2024-06-22 at 14.16.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/feacedd2-b54b-e959-1e16-8ec8ba42254d.png)

`Amazone S3 > Buckets`を選ぶと`You don't have permissions to list buckets`となります。そうです。`hello-user`はログイン以外アクセス権を何も持たないユーザーだからです。

![Screenshot 2024-06-22 at 14.19.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/45a3e7d5-b163-632b-2ef1-23dd56432a9b.png)

今度はAssumeRoleを試してみましょう。再度`IAM > Role`から`hello-role`を選びます。そして、`Link to switch roles in console`のURLをコピーします。

![Screenshot 2024-06-22 at 14.25.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/ee8c46b3-d644-6631-e0af-a01722ea9f3e.png)

そして、`hello-user`としてログイン済みのブラウザから、`Link to switch roles in console`のURLを叩くと、`Switch Role`の確認画面がでるので、`Switch Role`を選びます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/a99ee599-c00e-4fa0-ec1b-8ce4f3487dde.png)

Switch Roleができたら、右上のユーザーが「hello-role」になっている事を確認します。うまくSwitch Roleができたようです。

![Screenshot 2024-06-22 at 14.37.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/06109f60-f203-6434-85e0-5521dfe0c940.png)

コンソールから`Amazon S3 > Bucket List`へ

![Screenshot 2024-06-22 at 14.41.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/157e868b-0dec-7d28-3683-7bfe5a2b2392.png)

`hello-app-bucket`を選ぶと、オブジェクトをアップロードダウンロードできる事が確認できます。

![Screenshot 2024-06-22 at 14.52.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/5009b7bf-440b-720c-82cf-e8f1e7596ca8.png)

## Xcode 

Xcode でプロジェクトを作成します。今回Multiplatformを選んでみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/b6aefeba-4b95-1073-ea3e-b687cb44a50a.png)

適当すぎますが、プロジェクト名を`HelloAssumeRole`にしてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/0750e091-818f-c82c-dd97-87b115a2200c.png)

こんな所をスタート地点とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/4c546c5b-4bc1-077a-646b-8d77cbd72361.png)

`Project > HelloAssumeRole > Package Dependencies`からパッケージを追加します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/31885446-f7dd-9801-3349-abfb0faa2fb7.png)

今回は、aws0sdk-swiftを利用する事とします。ステータスは`Developer Preview`なので早く正式リリースを待ちたい所です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/692b2436-ab38-dafd-4c82-21b27e9a9e4d.png)

先にやっておけば良かったのですが、ここで、`hello-user`のアスセスキーとシークレットキーを取得します。`IAM > Users > hello-user` から `Access keys` の `Create access key`を選びます。

![Screenshot 2024-06-22 at 19.47.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/caa769d1-503a-b772-110d-22b8c1cda207.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/21e164e0-d62f-9490-f822-215b4a4927ef.png)

キーの作成が終わったらら、アクセスキーとシークレットキーをメモします。心配であれば、CSVをダウンロードしておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/6a627862-dc65-732b-499c-2e8ce0fb7983.png)

Xcodeに戻ります。

```ContentManager.swift
import Foundation
import AWSClientRuntime
import AWSSDKIdentity
import AWSS3
import AWSSTS
import Smithy
import SmithyIdentity

class ContentManager: ObservableObject {

	struct Item: Codable, Hashable {
		let uuid: UUID
		let timestamp: Date
		var timestampString: String {
			let formater = DateFormatter()
			formater.dateStyle = .long
			formater.timeStyle = .long
			return formater.string(from: self.timestamp)
		}
		var key: String {
			return (self.uuid.uuidString as NSString).appendingPathExtension("plist")!
		}
	}

	let accessKey: String = "YOUR_ACCESS_KEY"
	let secretKey: String = "YOUR_SECRET_KEY"
	let region: String = "YOUR_REGION"
	let roleArn: String = "YOUR_ROLE_ARN"
	let bucket = "YOUR_BUCKET_NAME"

	static let shared = ContentManager()
		
	private init() {
	}
	func makeSTSClient() throws -> STSClient {
		let identity = AWSCredentialIdentity(accessKey: self.accessKey, secret: self.secretKey)
		let resolver = try StaticAWSCredentialIdentityResolver(identity)
		let configuration = try STSClient.STSClientConfiguration(awsCredentialIdentityResolver: resolver, region: self.region, signingRegion: self.region)
		let stsClient = STSClient(config: configuration)
		return stsClient
	}
	func assumeRole() async throws -> STSClientTypes.Credentials {
		let stsClient = try self.makeSTSClient()
		let output = try await stsClient.assumeRole(input: AssumeRoleInput(roleArn: self.roleArn, roleSessionName: "content.manager"))
		guard let credentials = output.credentials else {
			throw NSError(domain: "ContentManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to assume role"])
		}
		return credentials
	}
	func makeS3Client() async throws -> S3Client {
		let credentials = try await self.assumeRole()
		guard let accessKeyId = credentials.accessKeyId, let secret = credentials.secretAccessKey, let sessionToken = credentials.sessionToken
		else { fatalError() }
		let identity = AWSCredentialIdentity(accessKey: accessKeyId, secret: secret, sessionToken: sessionToken)
		let resolver = try StaticAWSCredentialIdentityResolver(identity)
		let configuration = try await S3Client.S3ClientConfiguration(awsCredentialIdentityResolver: resolver, region: self.region, signingRegion: self.region)
		let s3Client = S3Client(config: configuration)
		return s3Client
	}
	private var _s3Client: S3Client?
	func s3Client() async throws -> S3Client {
		if let s3Client = self._s3Client { return s3Client }
		let s3Client = try await self.makeS3Client()
		self._s3Client = s3Client
		return s3Client
	}
	func listItems() async throws -> [String] {
		let result = try await self.s3Client().listObjectsV2(input: ListObjectsV2Input(bucket: self.bucket))
		return (result.contents ?? []).compactMap { $0.key }
	}
	func getItem(key: String) async throws -> Item? {
		let result = try await self.s3Client().getObject(input: GetObjectInput(bucket: self.bucket, key: key))
		guard let binary = try await result.body?.readData()
		else { throw NSError(domain: "getItem", code: -1, userInfo: [NSLocalizedDescriptionKey : "no body data"]) }
		guard let item = try? PropertyListDecoder().decode(Item.self, from: binary)
		else { throw NSError(domain: "getItem", code: -1, userInfo: [NSLocalizedDescriptionKey : "item can't be decoded"]) }
		assert(UUID(uuidString: (key as NSString).deletingPathExtension) == item.uuid) // TLDR: assume key name represent uuid
		return item
	}
	func putItem(_ item: Item) async throws {
		let binary = try PropertyListEncoder().encode(item)
		let result = try await self.s3Client().putObject(input: PutObjectInput(body: ByteStream.data(binary), bucket: self.bucket, key: item.key))
		print(result)
	}
	func deleteItem(_ item: Item) async throws {
		let key = (item.uuid.uuidString as NSString).appendingPathExtension("plist")
		let _ = try await self.s3Client().deleteObject(input: DeleteObjectInput(bucket: self.bucket, key: item.key))
	}
	func allItems() async throws -> [Item] {
		let keys = try await listItems()
		var items: [Item] = []
		for key in keys {
			if let item = try await getItem(key: key) {
				items.append(item)
			}
		}
		return items
	}
}

```

```ContentView.swift
import SwiftUI

struct ContentView: View {

	let contentsManager = ContentManager.shared
	
	@State var items = [ContentManager.Item]()
	@State var loadingCount = 0
	var body: some View {
		NavigationStack {
			VStack {
				List (self.items, id: \.self) { item in
					HStack {
						VStack {
							Text(item.uuid.uuidString)
							Text(item.timestampString)
						}
						Spacer()
						Button {
							self.deleteItem(item)
						} label: {
							Image(systemName: "trash")
						}

					}
				}
			}
			.toolbar {
				Button {
					self.reload()
				} label: {
					Image(systemName: "arrow.clockwise")
				}
				Button {
					Task {
						do {
							let item = ContentManager.Item(uuid: UUID(), timestamp: Date())
							try await self.contentsManager.putItem(item)
							self.reload()
						}
						catch {
							print("\(error)")
						}
					}
				} label: {
					Image(systemName: "plus")
				}
			}
		}
		.onAppear() {
			self.reload()
		}
		#if os(iOS)
		.navigationBarHidden(false)
		#endif
		.navigationTitle("Hello Assume Role")
		.overlay {
			if self.loadingCount > 0 {
				ProgressView("Now loading...")
			}
		}
	}
	func deleteItem(_ item: ContentManager.Item) {
		Task {
			self.loadingCount += 1
			defer { self.loadingCount -= 1 }
			do {
				try await self.contentsManager.deleteItem(item)
				self.reload()
			}
			catch {
				print("\(error)")
			}
		}
	}
	func reload() {
		Task {
			self.loadingCount += 1
			defer { self.loadingCount -= 1 }
			do {
				let items = try await contentsManager.allItems()
				self.items = items.sorted(by: { $0.timestamp < $1.timestamp })
			}
			catch {
				print("\(error)")
			}
		}
	}
}
```

デモアプリは`+`でアイテムの追加、Trashアイコンで削除、リロードアイコンでリフレッシュと単純な機能しかありません。S3に書き込んだデータは自動更新されないので、手動でリロードボタンを押す必要があります。やっていると、さらにこだわりたくなってきますが、ただの動作検証コードなので、ぐっと抑えます。アイテムは「+」をおした時にUUIDの生成とそのタイムスタンプを plist にしてS3のオブジェクトとして保存します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/38c19157-9b4a-de2e-c91d-e52e219b4d58.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/b93446d6-04d4-3560-b791-09e8af7527e3.png)

`AWS > S3 > hello-app-bucket`を覗くとちゃんと保存されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65634/d6cfe84b-c41b-b9eb-c8b2-052b8e6f1e5f.png)

## 最後に

動作検証コードはgithubにアップロードしました。ACCESS KEY関連は各自、自分のものを利用してください。

https://github.com/codelynx/HelloAssumeRole

なにせ数えきれないくらいの試行錯誤を繰り返して、ここまで辿り着いたので、関連性の低い事が重要そうに書いてあったり、別に重要な事がたまたまシナリオやユースケースや環境などによってたまたま動いているだけの可能性もあります。

個人的には、暗黙的にいろいろなリソースを勝手に使う`Amplify`はどうも好きになれないので、`AWS-SDK-Swift`で頑張っています。