サブスク特典の SaaS を「申請受付式」で配る設計 — Cloudflare Access JWT + 規約 NAS canonical + Postfix 通知でサーバーレス手前まで割り切った話
「全自動」より「最小受付」のほうが先に動く
はじめに
サブスク会員向けの自作 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 日数件しか発生しないであろう申請のためには重すぎます。頻度の低いオペレーションを自動化すると、コードがテストされる回数が少なくて壊れたまま気付かない、という別の問題も出ます。
代わりに次のフローにしました。
[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 か所で参照されます。
- 会員ポータルの申請画面(同意取得時に全文表示)
- ちらりんブログの
/legal/zta-terms/ページ(公開ドキュメント) - 将来的には SZTA portal 内の同意確認画面
3 か所が独立してコピーを持つと、改定時に必ずどこか 1 つが古いままになります。一度それで「掲載されている版と同意した版が違う」みたいな話になると、規約として致命的です。
なので canonical を NAS の 1 ファイルに固定しました。
//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 側のファイル冒頭に次のように書いておきます。
---
title: "Simple ZTA 利用特約"
version: "1.0"
effective_date: "2026-04-22"
---ポータルは申請時にこの version を szta_applications.terms_version カラムに格納します。規約改定で version を 1.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 を持たない、セッションも持たない という割り切りが効きます。
# 申請ハンドラの抜粋(イメージ)
@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 テーブルは次のようなカラムにしました。
| カラム | 型 | 意味 |
|---|---|---|
id | bigserial | 申請 ID |
user_id | bigint (FK) | 会員 ID |
status | text | pending / provisioned / rejected / revoked |
terms_version | text | 同意した規約バージョン |
applied_at | timestamptz | 申請日時 |
reviewed_at | timestamptz null | 管理者処理日時 |
reviewed_by | text null | 処理者(管理者メール) |
szta_tenant_id | uuid null | 紐付いた SZTA tenant |
szta_user_id | uuid null | 紐付いた SZTA user |
設計上のポイントは 同一 user に複数行の申請が存在し得る ことです。一度 revoked になった会員が再申請する、規約改定で再同意したい、といったケースに備えています。判定は「最新行の status」で行います。
つまり このテーブルは申請台帳であると同時に、同意ログでもある 構成です。terms_version と applied_at のペアが残っていれば、いつ・誰が・どの版に同意したかを後から証明できます。同意ログ専用テーブルを別に作る案も検討しましたが、β 版の規模では過剰でした。
将来 revoked 行を増やす予定なので、status カラムには CHECK 制約ではなく enum 風の運用ルールに留めました。SZTA 側の状態遷移が固まってから DB 制約に昇格させる予定です。
メール通知 — Postfix リレーで text/plain だけ
通知は身内の [email protected] に飛ばすだけなので、HTML メールも本文テンプレートエンジンも不要でした。SMTP リレー先は宅内の Postfix (172.16.1.11:25) を経由する構成です。
member-portal.env の関連部分はこれだけです。
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.comPython の smtplib を素のまま使い、本文も text/plain だけにしました。HTML メールは差出人ドメインの SPF/DKIM/DMARC 整備が前提になりますが、内部宛のテキストメールであれば Postfix 既設のリレー設定で十分です。ここに労力を割く理由がない、と言えるかが判断基準 でした。
メールに入れる情報は次の最低限に絞りました。
- 申請 ID
- 会員メールアドレス
- 同意した規約バージョン
- ポータルの該当申請ページへのリンク
詳細を見たくなったらリンクを踏んで管理画面に行く、という前提です。メールに全情報を詰め込むと、それ自体がコピー元になって機微情報の流出経路を増やします。
承認後のオペレーション — SQL を 1 本
申請が来たら、私はまず SZTA portal の管理画面で tenant と user を作ります。これは既存の admin UI を使うだけで、新しいコードは要りません。
その後、会員ポータル側の申請レコードを provisioned に更新します。
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.py—GET /szta/、POST /szta/apply、GET /szta/statusservices/mailer.py— Postfix リレー経由の text mail 送信models.py—SztaApplication定義templates/szta.html— 規約表示 + 状態バッジ + 申請フォームinit.sql—szta_applicationsテーブル定義
教訓として残しておきたいのは次の 4 点です。
- 頻度の低いオペレーションは無理に自動化しない。 テストされない自動化は、手動より壊れやすい
- 真実の置き場所を 1 つに決める。 同期方式が完璧でなくても、canonical の宣言だけは先にする
- 将来必要な事実は今から残す。 ロジックは後付けできるが、過去のデータは遡って埋められない
- 作らなかったものを書き出す。 「未実装」と「意図的に作らなかった」を区別すると、技術的負債の管理が楽になる
β 版が走り出して数件の申請を捌いたら、自動 provisioning と解約連動を作る予定です。ですがその時点でこの記事を読み返して、「全自動化が本当に必要か、もう一度疑う」 ところから始めるつもりです。
関連記事
- ちらりんブログ会員特典に Simple ZTA を追加します(β 版) — 今回の申請フローが繋がる先のサービス本体について
コメント