TECH · SAAS · DESIGN

サブスク特典の SaaS を「申請受付式」で配る設計 — Cloudflare Access JWT + 規約 NAS canonical + Postfix 通知でサーバーレス手前まで割り切った話

「全自動」より「最小受付」のほうが先に動く

tech 2026-04-26 38 min read by ちらりん
cover · 1024×1024

はじめに

サブスク会員向けの自作 SaaS β 版を、最初から全自動で配ろうとしないでください。

ちらりんブログのスタンダードプラン会員向けに、自作の ZTA サービス Simple ZTA を β 版として公開しました。会員ポータルから「お試し体験」できる導線が必要でしたが、tenant の自動 provisioning や解約連動の suspend を最初から作り込もうとすると、ポータル側に SZTA の API クライアントを生やすことになり、影響範囲が一気に膨らみます。

そこで割り切りました。受付・通知・同意ログだけ作って、tenant 作成は管理者が手動でやる。 SQL を 1 本叩けば終わるところまでは仕組みで支え、その先は人間が判断する。結果として、本番に出すまで 1 日で済みました。

この記事では「何を作って、何を意識的に作らなかったか」を中心に、3 つの設計判断と運用上のトレードオフをまとめます。読者層は、個人開発のサブスクサービスや、社内向け試用環境の払い出しフローを設計している方を想定しています。


設計の 3 つの判断

最初に全体像を出します。具体の話は後段で展開します。

判断採用した形捨てた選択肢
(a) 役割分担会員ポータル = 受付・通知・同意ログ / SZTA portal = tenant 管理ポータルから SZTA API を叩いて全自動 provisioning
(b) 規約の単一ソースNAS を canonical、portal は RO bind、Hugo は git copy各サービスがそれぞれ規約コピーを持つ(ドリフトの温床)
(c) 認証Cloudflare Access の JWT を信頼ポータル側で独自 user 管理・パスワード保管

要は 「自動化するほどではない部分は人間に残す」「真実は 1 ファイルに集める」「認証は外側の仕組みに乗っかる」 の 3 点です。


(a) 受付と provisioning を分けた

会員ポータルが SZTA portal の API を叩いて自動的に tenant とユーザーを切り出す、というのが教科書的な設計です。会員数が増えれば必須になるでしょう。ですが β 版で重視したのは「壊れにくいこと」と「公開を早めること」でした。

自動 provisioning には次のコストが乗ります。

  • ポータル → SZTA portal への API クライアント(mTLS or 共有秘密)
  • 失敗時の再試行・冪等性の確保
  • 部分的に作成された tenant のロールバック処理
  • SZTA 側の API 仕様変更への追従

このどれも、β 版で 1 日数件しか発生しないであろう申請のためには重すぎます。頻度の低いオペレーションを自動化すると、コードがテストされる回数が少なくて壊れたまま気付かない、という別の問題も出ます。

代わりに次のフローにしました。

snippet
[CF Access auth] members.chillarin39.com → /szta/
   ├ 規約全文 (NAS canonical を markdown→HTML)
   ├ 同意チェック + 申請ボタン
   └ POST /szta/apply
        ├ szta_applications テーブルに行追加(同意ログ兼)
        └ Postfix リレー → [email protected] 通知
              └ 管理者が SZTA portal で手動 tenant + user 作成
                  └ DB 手動 UPDATE で status=provisioned + szta_tenant_id/user_id 記入

ポータルがやるのは「申請を受け付ける」「履歴を残す」「管理者にメールを飛ばす」ところまで。tenant とユーザーの実体は、私が SZTA portal の管理画面を開いて作ります。最後に DB を 1 行 UPDATE すれば完了です。

ここで重要なのは 「将来自動化しやすい形で手動を残す」 ことです。szta_applications のスキーマは自動 provisioning が来ても変更不要なものにしてあります(後述)。手動の SQL UPDATE が API 呼び出しに置き換わるだけ、という余地を残しました。


(b) 規約は NAS を canonical にした

ZTA 利用特約は、3 か所で参照されます。

  1. 会員ポータルの申請画面(同意取得時に全文表示)
  2. ちらりんブログの /legal/zta-terms/ ページ(公開ドキュメント)
  3. 将来的には SZTA portal 内の同意確認画面

3 か所が独立してコピーを持つと、改定時に必ずどこか 1 つが古いままになります。一度それで「掲載されている版と同意した版が違う」みたいな話になると、規約として致命的です。

なので canonical を NAS の 1 ファイルに固定しました。

snippet
//TN-NAS1/projects/chillarin-legal/zta-terms.md   <- canonical
   ├ chillarin-sv01:/mnt/nas/projects/chillarin-legal/   (CIFS, fstab 永続化)
   │   └ portal container:/opt/legal/                    (RO bind mount)
   └ chillarin-blog (Hugo): content/legal/zta-terms.md   (git copy・次フェーズで NAS pull に統合)

会員ポータルは runtime で NAS のファイルを読みます。Hugo は build 時の取り込みがまだ整備できていないので、当面は git に手動コピーした版を使っています。「真の単一ソース化はまだ終わっていない」と認識した上で、運用上は NAS が常に最新であるルールにする ところまでが今回のスコープです。

front matter の version フィールドを使って同意ログを取ります。NAS 側のファイル冒頭に次のように書いておきます。

yaml
---
title: "Simple ZTA 利用特約"
version: "1.0"
effective_date: "2026-04-22"
---

ポータルは申請時にこの versionszta_applications.terms_version カラムに格納します。規約改定で version1.1 に上げれば、過去の申請者が同意した版と現行版が違うことを検知できます。再同意フロー本体は今回作っていません。 ですが将来の version 不一致検知に必要な情報は、今のうちに保存し始めるようにしました。

ここで意識したのは 「将来必要になる事実情報は今から残す。判定ロジックは後で良い」 という線引きです。後から DB スキーマを追加するのは可能ですが、過去のレコードに対して「あの時何の version に同意したか」を遡って埋めることはできません。


(c) 認証は Cloudflare Access の JWT を信頼した

members.chillarin39.com は Cloudflare Access で保護されています。会員はメンバー Google アカウントで Access にログインし、その先のオリジン(ポータルコンテナ)には JWT (Cf-Access-Jwt-Assertion ヘッダ) で身元が伝わります。

ポータル側でやるべきは JWT の検証だけです。具体的には次の 3 つです。

  • Cloudflare の JWKS から公開鍵を取得して署名検証
  • aud クレームが自分の Application の AUD タグと一致することを確認
  • email クレームを使って会員レコードと突き合わせ

これだけで「誰が申請したか」が確定します。ポータル側で独自にパスワードを持たない、TOTP を持たない、セッションも持たない という割り切りが効きます。

python
# 申請ハンドラの抜粋(イメージ)
@router.post("/szta/apply")
async def apply(request: Request, db: Session = Depends(get_db)):
    user = get_current_user(request)  # JWT から email を取り出して User を返す
    if not request.form.get("agree"):
        raise HTTPException(400, "同意チェックが必要です")

    terms_version = read_terms_version()  # /opt/legal/zta-terms.md の front matter から
    app = SztaApplication(
        user_id=user.id,
        status="pending",
        terms_version=terms_version,
        applied_at=datetime.utcnow(),
    )
    db.add(app)
    db.commit()
    send_admin_notification(user.email, app.id)  # Postfix リレー
    return {"ok": True, "application_id": app.id}

Cloudflare Access に user 管理を寄せたことで、ポータル側のコードは「申請を作る」「メールを送る」だけになりました。ZTA を作るときには、自分が ZTA の利用者でもあるべきです。 認証基盤を内製しないという判断は、結果として攻撃面の縮小にも繋がっています。


DB スキーマの判断 — 同意ログを兼ねる設計

SztaApplication テーブルは次のようなカラムにしました。

カラム意味
idbigserial申請 ID
user_idbigint (FK)会員 ID
statustextpending / provisioned / rejected / revoked
terms_versiontext同意した規約バージョン
applied_attimestamptz申請日時
reviewed_attimestamptz null管理者処理日時
reviewed_bytext null処理者(管理者メール)
szta_tenant_iduuid null紐付いた SZTA tenant
szta_user_iduuid null紐付いた SZTA user

設計上のポイントは 同一 user に複数行の申請が存在し得る ことです。一度 revoked になった会員が再申請する、規約改定で再同意したい、といったケースに備えています。判定は「最新行の status」で行います。

つまり このテーブルは申請台帳であると同時に、同意ログでもある 構成です。terms_versionapplied_at のペアが残っていれば、いつ・誰が・どの版に同意したかを後から証明できます。同意ログ専用テーブルを別に作る案も検討しましたが、β 版の規模では過剰でした。

将来 revoked 行を増やす予定なので、status カラムには CHECK 制約ではなく enum 風の運用ルールに留めました。SZTA 側の状態遷移が固まってから DB 制約に昇格させる予定です。


メール通知 — Postfix リレーで text/plain だけ

通知は身内の [email protected] に飛ばすだけなので、HTML メールも本文テンプレートエンジンも不要でした。SMTP リレー先は宅内の Postfix (172.16.1.11:25) を経由する構成です。

member-portal.env の関連部分はこれだけです。

bash
SMTP_HOST=172.16.1.11
SMTP_PORT=25
SMTP_FROM=[email protected]
SZTA_ADMIN_EMAIL=[email protected]
SZTA_TERMS_PATH=/opt/legal/zta-terms.md
SZTA_PORTAL_URL=https://szta.chillarin39.com

Python の smtplib を素のまま使い、本文も text/plain だけにしました。HTML メールは差出人ドメインの SPF/DKIM/DMARC 整備が前提になりますが、内部宛のテキストメールであれば Postfix 既設のリレー設定で十分です。ここに労力を割く理由がない、と言えるかが判断基準 でした。

メールに入れる情報は次の最低限に絞りました。

  • 申請 ID
  • 会員メールアドレス
  • 同意した規約バージョン
  • ポータルの該当申請ページへのリンク

詳細を見たくなったらリンクを踏んで管理画面に行く、という前提です。メールに全情報を詰め込むと、それ自体がコピー元になって機微情報の流出経路を増やします。


承認後のオペレーション — SQL を 1 本

申請が来たら、私はまず SZTA portal の管理画面で tenant と user を作ります。これは既存の admin UI を使うだけで、新しいコードは要りません。

その後、会員ポータル側の申請レコードを provisioned に更新します。

bash
docker exec -i member-db psql -U member_portal -d member_portal -c \
  "UPDATE szta_applications SET status='provisioned', \
   szta_tenant_id='<UUID>', szta_user_id='<UUID>', \
   reviewed_at=NOW(), reviewed_by='[email protected]' \
   WHERE id=<申請ID>;"

このコマンドは申請ごとに 1 回叩くだけです。SQL を直接書く運用には抵抗があるという方もいると思います。私の判断は 「申請が 1 日数件のうちは、管理 UI を作るより SQL を打つ方が速くて安全」 でした。

UI を作ると、フォームバリデーション、CSRF 対策、権限制御、テストが付いてきます。SQL を直接打つなら DB の制約だけで守れます。雑に作った UI のほうが裸の SQL より危険、というケースは現実にあります。

将来 SZTA portal の admin UI 側にこの UPDATE を統合します。tenant 作成と DB 更新を 1 トランザクションにすればミスが減るからです。ですがそれは申請件数が増えてからの話で、今は SQL で十分です。


何を意識的に作らなかったか

ここが今回の記事の本題です。作らなかったものを宣言することは、作ったものと同じくらい設計です。

機能現状次フェーズで作る理由
自動 provisioningなし(手動 SQL)申請件数が日に 1 桁を超えたら必要
解約連動の suspendなしStripe webhook → cf_access_service.sync_members フック追加で対応予定
規約改定の再同意フロー情報のみ保存(version カラム)version 不一致検知 + 同意取り直し UI が必要
Hugo build の NAS pull未実装(git copy)NAS canonical の真の単一ソース化のため
申請却下の理由通知なし却下事例が出てから設計する

このリストは見ようによっては「足りないことの一覧」ですが、私はむしろ 「Day 1 の本番投入のために合理的に削った機能の一覧」 だと考えています。

特に解約連動は早めに来そうなので、Stripe webhook ハンドラ側で SZTA tenant を suspend する関数の差し込み口だけは予め用意しました。「呼び出し元は決めたが、中身は空関数」という状態です。これも 将来必要なフックは形だけ作っておく という判断です。


ふりかえり — 最小受付に絞ったから 1 日で動いた

要件を「受付・通知・同意ログ」の 3 つに絞ったことで、本番投入まで 1 日で済みました。実装ファイルも小さく、/opt/chirarin-blog-infra/member-portal/app/ 配下で次の 5 つです。

  • routers/szta.pyGET /szta/POST /szta/applyGET /szta/status
  • services/mailer.py — Postfix リレー経由の text mail 送信
  • models.pySztaApplication 定義
  • templates/szta.html — 規約表示 + 状態バッジ + 申請フォーム
  • init.sqlszta_applications テーブル定義

教訓として残しておきたいのは次の 4 点です。

  • 頻度の低いオペレーションは無理に自動化しない。 テストされない自動化は、手動より壊れやすい
  • 真実の置き場所を 1 つに決める。 同期方式が完璧でなくても、canonical の宣言だけは先にする
  • 将来必要な事実は今から残す。 ロジックは後付けできるが、過去のデータは遡って埋められない
  • 作らなかったものを書き出す。 「未実装」と「意図的に作らなかった」を区別すると、技術的負債の管理が楽になる

β 版が走り出して数件の申請を捌いたら、自動 provisioning と解約連動を作る予定です。ですがその時点でこの記事を読み返して、「全自動化が本当に必要か、もう一度疑う」 ところから始めるつもりです。


関連記事

· · ·

コメント