# 経済産業大臣賞 B2B SaaS のアーキテクチャ徹底解剖：マルチテナント認可・冪等な決済・4ラウンドのセキュリティ監査

> 木材サプライチェーンのDXを実現したB2B SaaSを、実コードを唯一の真実源として解剖します。業種ベースのマルチテナント認可、Cognito RS256とJWKSキャッシュ、Stripe Connectの二層冪等性、ThreadPoolExecutorによる帳票並列生成、4ラウンドのセキュリティ監査、Slackアラーム偽装対策、コスト最適化までを実装レベルで解説。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: B2B SaaS, アーキテクチャ設計, AWS, Cognito, Terraform, Python, セキュリティ, Stripe
- URL: https://tomodahinata.com/blog/award-winning-b2b-saas-architecture-deep-dive

## 要点

- API Gateway→NLB→ALB→ECSの2段LBは冗長ではなく、VPC Link制約とL7制御から導かれた必然
- マルチテナント認可は業種のIntEnum＋frozensetホワイトリストをルーター層に一元化し、不一致は403で列挙攻撃を防ぐ
- 決済はサーバ側金額解決＋コンテンツアドレス冪等キー＋DynamoDB条件付き書き込みの二層冪等性で二重課金を断つ
- 4ラウンドのセキュリティ監査で全221エンドポイントの認証欠落0件を実証し、受容したリスクは台帳に明記する
- environment_active・Graviton・Fargate Spot・単一NATで、使わない期間は課金リソースをゼロに畳む

---

過去2本の記事では、木材流通業界のDXで得た「7つの教訓」と「技術選定フレームワーク」という*意思決定*の話を書きました。本記事はその続編にあたり、視点を「何を学んだか」から「実際にどう作り、どう本番運用に耐える品質まで引き上げたか」へ移します。

題材は、**経済産業大臣賞を受賞し、京都府認定も取得した B2B サブスクリプション SaaS**。林業・市場・製材所・プレカット・工務店・メーカーという多段の商流をまたいで、企業同士が受発注・帳票・決済・評価を行うマーケットプレイス型のプロダクトです。

この記事のルールはひとつ。**唯一の真実源は実コード**です。アーキテクチャ図やスライドではなく、実際に動いているバックエンド（Python / Flask）、フロントエンド（React / TypeScript）、インフラ（Terraform / AWS）から、エンタープライズが「この人に任せて大丈夫だ」と判断する材料になる設計判断だけを抜き出して解説します。

> **数字の前提**: 本文中の定量値（221 エンドポイント、204 マイグレーション、2,153 テスト、48 FK インデックス、17 Terraform モジュール、12 Lambda など）はすべてリポジトリから機械的にカウントした実測値です。一方で「事業ROI（工数削減%・コスト削減額）」はクライアントの実データが必要なため、本記事では扱いません。捏造はしない、という方針です。

---

## 1. システム全体像：本番のリクエストパス

まず全体像です。よくある「CloudFront → ALB → サーバ」よりも一段深い構成になっています。

```text
[ブラウザ] ── CloudFront ──> S3（React SPA / 不変アセット）

[ブラウザ] ── API Gateway（Cognito オーソライザー）
                  │  ※ 認証は VPC に入る前に終端
                  └─ VPC Link ─> NLB ─> ALB（internal）─> ECS Fargate ─> RDS PostgreSQL 16

[Stripe] ──> HTTP API ─> Lambda（webhook ×3）─> RDS / DynamoDB
[S3 アップロード] ──> Lambda（Excel/CSV 取り込み）─> RDS
[EventBridge 定期] ──> Lambda（課金アウトボックス送信 / 整合化）
[CloudWatch アラーム] ──> SNS ─> Lambda（Slack 通知）
```

なぜ `API Gateway → NLB → ALB` と LB を 2 段重ねるのか。これは「冗長」ではなく AWS の制約から導かれた必然です。

- **API Gateway のプライベート統合（VPC Link v1）は NLB しか指せない。** だから入口に NLB が必要。
- **L7 ルーティング・セキュリティグループによる ingress 制御・desync 対策が必要**なので、その後ろに ALB を置く。ALB は `desync_mitigation_mode = "defensive"`、`drop_invalid_header_fields = true` で運用。
- **Cognito オーソライザーを API Gateway のエッジに置く**ことで、認証は VPC に入る前に終端し、未認証リクエストは ECS まで到達しません。

重い処理・非同期処理は **すべて Lambda 側に分離**しています。Webhook、Excel 取り込み、課金調整、Slack 通知——これらを Flask アプリ本体から切り離すことで、API サーバは「同期リクエストの処理」という単一責任に集中でき、デプロイ単位も独立します（SRP）。

---

## 2. マルチテナント認可：業種ベースのアクセス制御

このプロダクトの設計上いちばん難しいのは「ユーザーが多種多様」という点です。`林業 / 市場 / 製材所 / プレカット / 製材所兼プレカット / 工務店 / メーカー` の 7 業種に、`閲覧` `管理` ロールが加わります。業種ごとに「実行できる操作」「閲覧できる情報」がまったく異なります。

### 業種は `IntEnum`、認可は `frozenset` のホワイトリスト

業種を文字列で散らすと、タイプミスや判定漏れが事故になります。そこで業種は `IntEnum` で一元定義し、機能ごとの許可業種を `frozenset` のホワイトリストとして宣言的に持ちます。

```python
class IndustryCode(IntEnum):
    FORESTRY = 0        # 林業
    MARKET = 1          # 市場
    SAWMILL = 2         # 製材所
    PRECUT = 3          # プレカット
    SAWMILL_PRECUT = 4  # 製材所兼プレカット
    BUILDER = 5         # 工務店
    MANUFACTURER = 6    # メーカー
    VIEWER = 99
    ADMINISTRATOR = 100

# 「丸太を発注できるのは誰か」を frozenset で宣言的に定義する
WOOD_ORDER_INDUSTRIES = frozenset({IndustryCode.PRECUT, IndustryCode.SAWMILL_PRECUT})
LOG_RECEIVER_INDUSTRIES = frozenset({IndustryCode.FORESTRY, IndustryCode.MARKET})


def check_industry(user: User, allowed: frozenset[IndustryCode]) -> None:
    # 管理者は常に通す。それ以外は許可業種外なら 403。
    if user.industry == IndustryCode.ADMINISTRATOR:
        return
    if user.industry not in allowed:
        # 404 ではなく 403 を返す（リソース存在の有無を漏らさない＝列挙攻撃対策）
        raise Forbidden()
```

ポイントは 3 つです。

1. **認可判定はルーター層に一元化**する。UseCase / Repository は「すでに認可済みの `User`」を受け取る前提で書くので、ビジネスロジックに認可の if 文が散らばりません。
2. **不一致は `403`**（`404` ではない）。これは「IDを総当たりして存在を推測する」列挙攻撃を防ぐための明示的な設計判断です。
3. **管理者バイパスは一箇所だけ**。例外を一点に閉じ込めることで、認可の抜け道が増殖しません。

### PII を漏らさない「二層スキーマ境界」

企業をまたいで取引相手を探すマーケットプレイスでは、「相手企業の概要は見せたいが、メール・電話・法人番号は見せたくない」という要件が生まれます。これを**スキーマの分離**で構造的に解いています。

```python
class UserDumpSchema(BaseSchema):
    """相互に取引関係がある相手にだけ使う。PII を含む完全な表現。"""
    email = fields.Email()
    phone_number = fields.String()
    corporate_number = fields.String()
    # ... PII を含む全フィールド

class UserPublicSchema(BaseSchema):
    """企業横断の検索・閲覧で使う公開スキーマ。PII は『許可リスト方式』で構造的に除外。"""
    user_id = fields.UUID()
    company_name = fields.String()
    industry = fields.Integer()
    prefecture = fields.String()
    average_rate = fields.Float()      # 0〜5 の企業評価
    evaluation_count = fields.Integer()
    # email / phone_number / corporate_number は『定義していない』ので絶対に出ない
```

ブラックリスト（「これを隠す」）ではなく**ホワイトリスト（「これだけ出す」）**で実装しているのが肝です。新しい PII フィールドを `User` に足しても、`UserPublicSchema` に明示追加しない限り公開経路には絶対に出てきません。後述するペネトレーションテストで、まさにここの取り違え（横断 API が `UserDumpSchema` を使っていた）を検出し、即日修正しています。

### フロントエンド：多段ゲートの `ProtectedRoute`

バックエンドの認可は最後の砦ですが、UX のためにフロントでも段階的にゲートします。React 側は `ProtectedRoute` が「認証 → プロフィール完備 → 管理者 → サブスク有効 → 業種」の順に判定し、いずれかで弾かれたら適切なリダイレクトを返します。

```tsx
function ProtectedRoute({
  requiredIndustries,
  requiresAdmin,
  children,
}: {
  requiredIndustries?: ReadonlySet<Industry>;
  requiresAdmin?: boolean;
  children: React.ReactNode;
}) {
  const { user, isLoading } = useAuth();

  if (isLoading) return <Loading />;
  if (!user) return <Navigate to="/login" replace />;
  if (isProfileIncomplete(user)) return <Navigate to="/onboarding" replace />;
  if (requiresAdmin && !user.is_admin) return <Navigate to="/" replace />;
  if (IS_PROD && user.subscription_status !== "active")
    return <Navigate to="/subscription" replace />;
  if (requiredIndustries && !requiredIndustries.has(user.industry))
    return <Navigate to="/" replace />;

  return <UserContext value={user}>{children}</UserContext>;
}
```

業種ごとの機能境界（市場ユーザー向け、製材所向け、直送送り手向け…）は、この `ProtectedRoute` を薄くラップしたルートで表現します。**「許可業種の集合」をデータとして渡す**ので、機能とロールの対応を一覧で見渡せ、変更も局所化されます（ETC）。

---

## 3. 認証基盤：Cognito JWT（RS256）と JWKS キャッシュ

認証は Amazon Cognito。バックエンドは渡ってきた JWT を RS256 で検証します。ここで「ライブラリに任せて終わり」にしないのが本番品質の分かれ目です。

```python
def verify_token(token: str) -> dict:
    signing_key = get_jwks_client().get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience=COGNITO_CLIENT_ID,
        issuer=COGNITO_ISSUER,
        # exp / iat / iss / aud / token_use の存在を必須化する
        options={"require": ["exp", "iat", "iss", "aud", "token_use"]},
    )
    # access トークンの誤用を防ぐ：id トークン以外は拒否する
    if claims.get("token_use") != "id":
        raise Unauthorized("invalid token_use")
    return claims
```

`algorithms=["RS256"]` を明示しているので、古典的な `alg=none` 攻撃や HS256 へのダウングレードは成立しません。`token_use == "id"` の検証は、access トークンを使って id トークン用エンドポイントを叩く混同を塞ぐためのものです。

### JWKS は「二重チェックロックのシングルトン」でキャッシュ

JWKS（公開鍵セット）をリクエストごとに取りに行くと、レイテンシも増えるし Cognito へ無駄に負荷をかけます。一方で Fargate はマルチワーカーなので、ナイーブなグローバル変数は競合します。そこで**ダブルチェックロッキング**でクライアントを 1 つだけ生成します。

```python
_jwks_lock = threading.Lock()

def get_jwks_client() -> PyJWKClient:
    client = current_app.config.get("COGNITO_JWKS_CLIENT")
    if client is not None:           # 1st check（ロックなしの高速パス）
        return client
    with _jwks_lock:
        client = current_app.config.get("COGNITO_JWKS_CLIENT")
        if client is None:           # 2nd check（ロック内で再確認）
            client = PyJWKClient(COGNITO_JWKS_URI)
            current_app.config["COGNITO_JWKS_CLIENT"] = client
        return client
```

JWKS の更新間隔は当初 24 時間でしたが、セキュリティ監査の指摘を受けて **6 時間**へ短縮し、起動時にプリウォーム（同期的に 1 回取得）しています。

なお、**ステートレス JWT のトレードオフは正直に文書化**しています。Cognito の `GlobalSignOut` は refresh トークンを失効させますが、id/access トークンは `exp`（約 60 分）まで有効です。これを即時失効させるには denylist（ElastiCache）が要りますが、コスト・レイテンシ・SPOF に見合わないと判断し、「短い TTL ＋ リクエストログ ＋ セキュリティヘッダ」で受容しています。**受容したリスクを台帳に残す**のは、エンタープライズ向けでは「隠す」より遥かに信頼されます。

---

## 4. 冪等な決済：Stripe Connect の二層冪等性

このプロダクトは「継続課金（サブスク）」と「取引ごとの精算」が同居するため、**Stripe Connect** を採用しています。決済では、ネットワーク断やリトライ下でも `二重課金` `取りこぼし` を絶対に起こさないことが要件です。

### サブスク状態は `User` に置き、DB の CHECK 制約で形式検証

```python
class User(Base):
    stripe_customer_id: Mapped[str | None]
    stripe_subscription_id: Mapped[str | None]
    stripe_connect_account_id: Mapped[str | None]
    subscription_status: Mapped[SubscriptionStatus]

    __table_args__ = (
        # Stripe ID の形式を DB レベルで検証（偽造・不正値の混入を防ぐ）
        CheckConstraint("stripe_customer_id LIKE 'cus_%'", name="ck_stripe_customer_id"),
        CheckConstraint("stripe_subscription_id LIKE 'sub_%'", name="ck_stripe_subscription_id"),
        CheckConstraint("stripe_connect_account_id LIKE 'acct_%'", name="ck_stripe_account_id"),
    )
```

**金額は常にサーバ側で解決**します。クライアントから渡ってきた `amount` を Stripe にそのまま流すのは、初期の監査で検出された典型的な「金額改ざん」脆弱性でした。現在は注文内容から金額を再計算する `AmountResolver` を通します。

### 第 1 層：コンテンツアドレス方式の冪等キー

Stripe API 呼び出しには冪等キーを付けますが、キーをランダムにすると「同じ操作のリトライ」と「内容が変わった再操作」を区別できません。そこで**内容のハッシュ**をキーに織り込みます。

```python
def idempotency_key(adjustment_id: str, scope: str, params: dict) -> str:
    digest = hashlib.sha256(canonical_json(params).encode()).hexdigest()[:12]
    # 同じ内容 → 同じキー（安全に再送できる）
    # 内容が変わる → 別キー（24h の IdempotencyKeyConflict を踏まない）
    return f"adj_{adjustment_id}_{scope}_{digest}"
```

### 第 2 層：DynamoDB の条件付き書き込みで Webhook を重複排除

Stripe Webhook は「少なくとも 1 回」配信されます。つまり同じイベントが複数回飛んできます。これを DynamoDB の条件付き `PutItem` で冪等化します。

```python
def already_processed(event_id: str) -> bool:
    try:
        table.put_item(
            Item={"event_id": event_id, "ttl": now() + THIRTY_DAYS},
            ConditionExpression="attribute_not_exists(event_id)",
        )
        return False  # 初回 → 処理する
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return True   # 既処理 → スキップ
        # DynamoDB 側の障害は fail-open（Stripe が再送するので最終的整合に倒す）
        log.warning("idempotency check degraded", exc_info=True)
        return False
```

ここには「障害時にどちらへ倒すか」という明確な判断が 2 つあります。

- **テーブル名の環境変数が未設定なら、import 時点で `RuntimeError` を投げて起動を止める（fail-closed）。** 冪等性の仕組みが黙って無効化される事故を防ぎます。
- **DynamoDB 自体が落ちているときは fail-open**（処理を進める）。Stripe が再送してくれるので最終的整合に倒すほうが、決済を止めるより害が小さい、という判断です。

さらに課金調整は **トランザクショナル・アウトボックス**で実装しています。業務トランザクションと同じ DB トランザクションで `outbox` 行を書き、別 Lambda（EventBridge で 5 分ごと起動）が未送信分を Stripe へ送る。送信失敗しても行が残るので、確実に届きます。1 時間ごとの整合化 Lambda が突き合わせを行い、監査ログ用テーブルに記録します。

---

## 5. 重い処理の並列化と非同期化

「見積書・納品書・請求書」の Excel/PDF 生成と「既存 Excel の DB 化」は、このプロダクトでもっとも重い処理です。ここを素朴に同期実行すると、管理画面が固まります。

### 帳票生成：`ThreadPoolExecutor` でスレッド並列

1 つの注文に対して注文書・納品書・請求書を*同時に*生成します。Flask のアプリコンテキストはスレッドローカルなので、各スレッドで明示的に張り直すのが要点です。

```python
def parallel_create_documents(app, order_id: str) -> None:
    tasks = [create_order_form, create_delivery_note, create_invoice]

    def run(task):
        with app.app_context():                      # スレッドごとにコンテキストを張る
            doc = (
                Document.query
                .options(selectinload(Document.lines))  # N+1 を選択ロードで回避
                .filter_by(order_id=order_id)
                .with_for_update()                       # 行ロックで競合生成を防ぐ
                .one()
            )
            return task(doc)

    with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
        futures = [pool.submit(run, t) for t in tasks]
        for f in as_completed(futures):
            f.result()  # 最初の例外を伝播させる
```

Excel は `openpyxl`、PDF は LibreOffice のヘッドレス変換を使います（Celery や asyncio ではなく、CPU/IO バウンドな帳票生成にはスレッドプールが素直で十分、という判断です）。

### Excel/CSV 取り込み：S3 イベント駆動 Lambda

業界の共通言語である Excel を一括取り込みする処理は、API サーバから切り離して **S3 アップロードをトリガーにした Lambda** にしています。`openpyxl` を `read_only=True` で開き、`psycopg2` の `execute_values` で一括 INSERT します。ここでも防御を 2 つ入れています。

- **アップロードは 50MB 上限**（`HeadObject` で事前チェックし、OOM を防ぐ）。
- **CSV/Excel の数式インジェクション無害化（CWE-1236）**：`=` `+` `@` `-` で始まる値は先頭に `'` を付けて、開いた先の表計算ソフトで数式として実行されるのを防ぐ。

### フロントエンド：指数バックオフ＋Page Visibility 対応のポーリング

重い処理の完了をフロントで待つとき、固定間隔の `setInterval` は背面タブでも API を叩き続けてコストを無駄にします。そこで**指数バックオフ＋画面の可視状態連動**のポーリングフックを実装しています。

```ts
usePollingWithBackoff({
  baseIntervalMs: 1000,
  maxIntervalMs: 30_000,           // 1 → 2 → 4 → … → 30s と伸ばす
  onTick: async () => {
    const next = await refetch();
    // 全件が success / failure に達したら停止
    return { shouldStop: next.every(isTerminal) };
  },
});
// document.hidden の間はリクエストを抑止し、再表示で base に戻す
```

背面タブで API を浪費しないので、ユーザー体験とクラウドコストの両方に効きます。

---

## 6. データベースの効率と信頼性

PostgreSQL 16 上で、地味だが効く改善を積んでいます。

| 改善 | 内容 |
|------|------|
| **FK インデックス 48 本を無停止追加** | 不足していた外部キーのインデックスを `CREATE INDEX CONCURRENTLY` ＋ `IF NOT EXISTS` で追加。本番で `ACCESS EXCLUSIVE` ロックを取らない |
| **コネクションプールの予算化** | `pool_size=5 / max_overflow=5 / pool_recycle=1800 / pool_pre_ping=True`。`(5+5)×8タスク = 80 < db.t4g.micro の上限`。スケールアウト時の接続枯渇を予防 |
| **日報の N+1 解消** | サイト数に比例して flush していた upsert を `add_all` ＋ 単一 flush に。flush 回数をサイト数によらず定数化 |
| **テストのセーブポイント分離** | テストごとに savepoint を張ってロールバック。全件を約 11 秒で実行でき、CI が高速 |

`CREATE INDEX CONCURRENTLY` を `autocommit_block` の中で実行し、命名規約を SQLAlchemy の自動生成（`ix_<table>_<column>`）に揃えることで、マイグレーションの drift をゼロに保っています。マイグレーションは Alembic で **204 世代**を版管理しており、既存マイグレーションは決して編集しない運用です。

---

## 7. 4ラウンドのセキュリティ監査

ここがエンタープライズの信頼を勝ち取る核心です。このプロダクトは**4 ラウンドのセキュリティ監査と資格情報ローテーション**を経ています。

| ラウンド | 手法 | 主な所見と対応 |
|---------|------|--------------|
| **R1** | 静的ソース監査 | 40 件（Critical 4 / High 17 / Medium 14 / Low 5）。**決済整合性**が中心：クライアント指定 `amount` の改ざん、テスト課金バイパス、管理セッション Cookie の `httponly=False`。Critical を 4/4 クローズ |
| **R3** | ライブ・ステージング診断（約250リクエスト） | Critical 2：Lambda 環境変数の**平文資格情報**（→ Secrets Manager 移行）、Webhook 冪等性の **fail-open**（→ fail-closed）。パストラバーサル・JWT 偽造（`alg=none`/改ざん aud）・CORS リフレクション・Webhook 署名検証はすべて防御成功 |
| **R4** | ブラックボックス＋ホワイトボックス・ペネトレ | 実在 **15 ロール**の Cognito ユーザーで全 **221 エンドポイント**を走査。**認証欠落 0 件**。High 1 件（横断 API のクロステナント PII 露出）を当日に修正・再診断で 0 件確認 |
| **R5** | カテゴリ網羅（SSRF/CSRF/XXE/IDOR/SSTI 等 14 種） | 新規所見 0 件。RDS の `rds.force_ssl=1` 強制、通知のレート制限などを追加実装 |

R4 の「全 221 エンドポイントで認証欠落 0 件」は、すべてのルートが API Gateway の Cognito オーソライザーで守られていることを、実際の攻撃者視点で実証した結果です。`/health` すら未認証で素通りしません。

セキュリティヘッダも整備済みです。

```text
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'
```

そして、**「直しきれない／直さないと決めた」残存リスクは台帳に明記**しています（前述の JWT 失効トレードオフ、Cognito の `SignUp` によるユーザー存在の推測可能性など）。これは ADR（設計判断の記録）に近い運用で、第三者が「何を分かったうえで何を選んだか」を追跡できます。

---

## 8. 可観測性と回復性

### Slack アラーム偽装（ログインジェクション）への対策

運用アラートは Slack に流していますが、当初の「失敗マーカー」はログの先頭トークンを見る位置依存の文字列でした。CloudWatch は行頭の空白を削るため、改行を仕込んだ入力で**偽の運用アラームを発火**できてしまう——古典的なログインジェクション（CWE-117）です。

対策として、マーカーを**単一行のトップレベル JSON** に変えました。

```python
# 偽装可能だった旧実装（位置依存の文字列）:  "[w1=..., FAILED]"
# 偽装不能な新実装（構造化）:
log.error(json.dumps({"marker": "SLACK_DELIVERY_FAILED", "reason": reason}))
```

CloudWatch のメトリクスフィルタは `{ $.marker = "SLACK_DELIVERY_FAILED" }` で照合するので、本文に何を書かれても `marker` キーは偽造できません。

### Slack が落ちても気づける「帯域外」エスカレーション

通知経路自体が壊れたら本末転倒です。そこで Slack 配信失敗を上記マーカーで検知し、**CloudWatch メトリクスフィルタ → SNS メール**という、Slack に依存しない経路へエスカレーションします。さらにマーカー文字列がバックエンド／Lambda／Terraform の 3 箇所でズレないよう、**契約テスト**で機械的に同期を保証しています。

ERROR ログの Slack 通知ハンドラ自体も、レスポンスを「恒久失敗（4xx）」と「一時失敗」に分類し、一時失敗だけ指数バックオフでリトライ、恒久失敗は即中止して上記マーカーを出します。スレッド／グリーンレット混在環境のため、`requests.Session` を共有しスレッドセーフに扱っています。

---

## 9. コスト最適化

「世界最高峰の品質」はコスト無視を意味しません。むしろ**個人〜小規模で本番 SaaS を回す**には、コスト設計こそ実力が出ます。

- **`environment_active` 一発で課金リソースをゼロに畳む。** 停止期間は NAT・ALB/NLB・VPC Link・RDS（`count=0`）・ECS（`desired=0`）・Secrets Manager の VPC エンドポイントがすべて消える。再開もコードから。
- **Graviton（ARM / t4g）** を RDS とバスティオンに採用し、x86 比でコスト効率を改善。
- **ステージングは Fargate Spot 100%**（中断許容、約 70% 削減）。本番はオンデマンド。
- **NAT は単一**（冗長を捨ててコスト最適）。VPC エンドポイントは本番のみ。
- **スケジュールスケーリング**：本番の ECS を平日業務時間は min2/max8、夜間は min1/max4 に。
- **Terraform state は S3 ネイティブロック**（`use_lockfile = true`）で、DynamoDB ロックテーブルのコストを排除。
- **S3 ライフサイクル**（本番のみ）：Standard → IA(30d) → Glacier(90d) → 削除(365d)。
- **CMK は本番のみ**、ステージングは AWS マネージドキー。

「平常時は安く、必要なときだけスケールし、使わない期間はゼロに畳める」——この弾力性をコードで宣言しているのが要点です。

---

## 10. CI/CD と品質ゲート

最後に、これらの品質を**人手のレビューに頼らず維持する**仕組みです。

**デプロイ**は GitHub Actions の **OIDC**（長期 AWS キーを一切持たない）で実行します。バックエンドは `docker build → ECR push → ecs update-service --force-new-deployment`、フロントは `S3 sync（不変アセットは immutable、ルートは no-cache）→ CloudFront 無効化`。

**Terraform** も 3 本のパイプラインで自動化しています。

- `plan`：PR で `fmt-check` / `validate` / `tfsec` を実行し、plan をコメント。
- `apply`：`main` → staging、`production` ブランチ → 本番。**apply ロールには権限境界（permissions boundary）**を付け、組織乗っ取りや監査基盤の停止を構造的に禁止。
- `drift`：平日朝に定期実行し、ドリフトを検知したら GitHub Issue を自動起票・復旧で自動クローズ。

**品質ゲート**は二段構えです。`pre-commit`（変更ファイルのみ、数秒）と `pre-push`（CI のフルミラー）で、以下を回します。

| 層 | フォーマット | Lint | 型 | セキュリティ | テスト |
|----|-----------|------|----|-----------|--------|
| Backend | Ruff | Ruff / Bandit / Vulture / deptry | mypy | pip-audit | pytest（Docker, 2,153 件） |
| Frontend | Prettier | ESLint | tsc --noEmit | npm audit | Vitest |
| Infra | terraform fmt | terraform validate | — | tfsec | terraform plan |

さらに `gitleaks`（秘密情報スキャン）、`Trivy`（イメージ CVE）、`hadolint`、`Dependabot`（6 エコシステムを継続更新）。Conventional Commits を必須化し、`--no-verify` と main への force push は禁止です。

---

## まとめ

経済産業大臣賞という結果の裏側には、派手な機能ではなく、**地味で一貫した設計判断の積み重ね**があります。

- **マルチテナント認可**は `frozenset` ホワイトリスト＋ルーター層一元化＋PII ホワイトリストスキーマで、構造的に漏れない形に。
- **決済**は「サーバ側金額解決＋コンテンツアドレス冪等キー＋DynamoDB 重複排除＋アウトボックス」で、リトライ下でも二重課金しない。
- **重い処理**はスレッド並列とイベント駆動 Lambda へ分離し、フロントは可視状態連動のバックオフポーリングで待つ。
- **信頼性**は、4 ラウンドの監査・221 API の認証欠落 0 件・ログインジェクション対策・帯域外エスカレーション・受容リスク台帳という形で*実証可能*に。
- **コスト**は `environment_active`・Graviton・Spot・単一 NAT で、弾力的かつ安価に。

「動くものを作る」と「本番運用に耐え、第三者の攻撃と監査に耐える SaaS を作る」の差は、まさにこういう一つひとつの判断にあります。レガシー産業の DX や、B2B SaaS の新規開発・立て直しをご検討中でしたら、要件定義からインフラ・セキュリティ・運用まで、この水準でワンストップにお引き受けします。
