マルチプロダクト基盤を3サービスで構成する — 認証・認可・課金の責任分離

マルチプロダクト向けの共通基盤を認証(rellf-auth)・認可(rellf-authz)・課金(rellf-billing)の3サービスで構成する設計。依存関係、データフロー、契約プランに応じた認可の実装パターンを整理。

はじめに

複数のプロダクトを運営するとき、認証・認可・課金は各プロダクトで個別に実装するのではなく、共通基盤として切り出す方が効率的です。

ただし「共通基盤」が 1 つの巨大なサービスになると、変更のリスクが高く、リリースサイクルも遅くなる。この記事では、3 つのサービスに責任を分離する設計を整理します。

3 サービスの全体像

イベント基盤

billing 内部

authz 内部

auth 内部

rellf 基盤

プロダクト

SDK

ブラウザ

OIDC + PKCE

Authorization Code

Access Token

Access Token

1. JWT検証 (JWKS)

2. エンタイトルメント

3. リソース認可

SDK

OAuth 2.0

トリガー

送信

UserDeleted

PlanChanged

通知

キャッシュ無効化

ブラウザ / SPA

@rellf/sdk
OIDC + PKCE
トークン管理

rellf-sdk-go
JWT検証 + Can()

プロダクト A
(cases.example.com)

プロダクト B
(docs.example.com)

rellf-auth
認証
あなたは誰?

rellf-authz
認可
何ができる?

rellf-billing
課金
プランは?

Cognito User Pool

Google OAuth

SES
メール送信

Lambda (Go/Gin)

CustomMessage
PreSignup

AVP
Cedar ポリシー

Policy Store
Product A

Policy Store
Product B

Lambda (Go/Gin)

DynamoDB
orgs / plans /
licenses / usage

Lambda (Go/Gin)

EventBridge

リクエスト処理の流れ

rellf-authzrellf-billingrellf-authプロダクト API(Go SDK)ブラウザrellf-authzrellf-billingrellf-authプロダクト API(Go SDK)ブラウザ初回ログインAPI リクエストOIDC Authorization Code + PKCEID Token + Access TokenGET /api/cases/123Authorization: Bearer {token}JWKS 取得(キャッシュ)JWT 署名検証sub, groups, auth_time, amrエンタイトルメントチェック「org-abc は cases を使えるか?」✓ 使える (pro プラン)リソースレベル認可「user-xxx は case-123 を view できるか?」✓ permit (担当者)200 OK { case data }

各サービスの責任

サービス責任持つデータ他プロダクトへの提供
rellf-auth認証、ユーザー ID 発行sub, email, groups, MFAJWT (OIDC)
rellf-authzリソースレベル認可判定Cedar ポリシーIsAuthorized API
rellf-billing契約・プラン・エンタイトルメント・使用量org, plan, 上限, ライセンスキーエンタイトルメント API

判定の 2 段階

段階担当問い
1. エンタイトルメントbillingこのプランでこのプロダクト/機能が使えるか?pro なら cases は使える
2. リソースレベルauthzこのユーザーはこのリソースに対して操作できるか?この案件の担当者か

billing がプロダクトレベルのゲート、authz がリソースレベルの細かい判定。プラン変更は billing のエンタイトルメント更新だけで即反映され、Cedar ポリシーの変更は不要。

なぜ 3 つに分けるか

異なるライフサイクル

サービス変更頻度ダウンの影響
auth低い(認証方式を頻繁に変えない)全プロダクト停止
authz中(ポリシー追加は日常的)権限判定が止まる
billing高い(プラン変更、料金改定、機能追加)課金が止まる

変更頻度が最も高い billing の改修で、auth が巻き込まれるのは避けたい。

異なるデータ特性

サービスデータの性質
authユーザー単位。高い可用性と一貫性が必要
authzポリシー単位。読み取りが大部分
billing組織単位。金銭に関わるので正確性が最優先

循環依存がない

rellf-auth ←───── 全プロダクト(JWT 検証)

     │ sub / groups

rellf-authz ←──── プロダクト(認可判定)

     │ context.plan

rellf-billing ←── プロダクト(プラン確認・使用量記録)

この「直接依存しない」のがポイントです。認可エンジンが課金 DB を直接見に行く設計にすると密結合になり、どちらかの障害がもう一方に波及します。

契約プランに応じた認可

「pro プランなら案件数無制限、free プランなら 10 件まで」のような制御をどう実現するか。

3 つの関心事を分離する

関心事問い管理場所
契約プランこの組織はどのプランか?rellf-billing
エンタイトルメントこのプランで何が使えるか?rellf-billing(設定)
認可判定このユーザーは今この操作ができるか?rellf-authz

データフロー

[ユーザーがリクエスト]


[プロダクト API]

     ├─ 1. JWT 検証 → rellf-auth の JWKS
     │     sub: user-123, groups: ["lawyer"]

     ├─ 2. プラン取得 → rellf-billing
     │     org: org-abc, plan: "pro"

     └─ 3. 認可判定 → rellf-authz
           {
             principal: { id: "user-123", groups: ["lawyer"] },
             action: "create_case",
             context: { plan: "pro", current_count: 42 }
           }
           → permit

プロダクトが 3 つのサービスから情報を集め、context として認可エンジンに渡す。組み立ての責任はプロダクト側にあるのがポイントです。

Cedar ポリシーの例

// pro 以上ならレポート機能にアクセス可能
permit(
  principal,
  action == App::Action::"view_reports",
  resource
) when {
  context.plan in ["pro", "enterprise"]
};

// free プランは案件数 10 件まで
forbid(
  principal,
  action == App::Action::"create_case",
  resource
) when {
  context.plan == "free" &&
  context.current_case_count >= 10
};

JWT にプラン情報を入れるべきか

メリットデメリット
入れる毎回課金 DB を引かなくていいプラン変更が JWT 再発行まで反映されない
入れないプラン変更が即反映毎リクエスト課金 DB を引く

入れない方がいいです。理由:

プロダクト側で数分の TTL でキャッシュすれば十分です。

rellf-billing のスコープ

最小構成(初期)

機能エンドポイント
組織のプラン取得GET /api/orgs/:id/plan
プラン変更POST /api/orgs/:id/plan
使用量記録POST /api/orgs/:id/usage
使用量取得GET /api/orgs/:id/usage
エンタイトルメント取得GET /api/plans/:plan/entitlements

将来の拡張

機能追加タイミング
Stripe 連携外部ユーザーへの課金が必要になったとき
請求書発行法人契約が始まったとき
使用量アラート上限に近づいたら通知
トライアル管理無料試用期間を提供するとき

最初は「組織 × プランのマッピング + エンタイトルメント取得」だけで十分です。

技術スタックの統一

3 つとも同じ構成にすることで、開発・運用のオーバーヘッドを最小化できます。

rellf-authrellf-authzrellf-billing
言語Go / GinGo / GinGo / Gin
実行環境Lambda (arm64)Lambda (arm64)Lambda (arm64)
APIAPI Gateway v2API Gateway v2API Gateway v2
IaCTerraformTerraformTerraform
ドメインauth.rikuka.devauthz.rikuka.devbilling.rikuka.dev
データストアCognitoAVP (Cedar)DynamoDB

プロダクト側の SDK

3 サービスを毎回個別に呼ぶのはプロダクト側の負担になるので、共通の SDK(Go パッケージ)を提供するのが理想です。

// プロダクト側のコード
client := rellf.NewClient(rellf.Config{
    AuthJWKS:    "https://auth.rikuka.dev/oidc/jwks.json",
    AuthzURL:    "https://authz.rikuka.dev",
    BillingURL:  "https://billing.rikuka.dev",
})

// ミドルウェアで JWT 検証
router.Use(client.Auth.Middleware())

// ハンドラーで認可判定(プラン情報も自動で取得)
allowed, err := client.Can(ctx, rellf.AuthzRequest{
    Principal: user.Sub,
    Action:    "create_case",
    Resource:  "Case",
})

SDK が内部で auth → billing → authz の順にデータを集めて判定する形にすれば、プロダクト側は client.Can() 1 行で済みます。

まとめ

← Back to all posts