Share
Embed
Scalaの型でマルチテナントの認可を守る
Nextbeat Tech Bar #22:Scala について語る会
「Scalaの型でマルチテナントの認可を守る」の発表資料
https://nextbeat.connpass.com/event/407155/
nextbeat-engineer
November 13, 2026
Transcript
22 slides
-
Scalaの型でマルチテナントの認可を守る 株式会社ネクストビート 執行役員 CTO 溝上友貴 Nextbeat Tech Bar #22(2026-11-13) Copyright
© nextbeat All Rights Reserved -
自己紹介 名前 溝上 友貴 所属 株式会社ネクストビート 部署 Technology Division 役割
執行役員 CTO 経歴 SWE 5年 / PdM 1年 / CTO 1年 SNS taretmch Copyright © nextbeat All Rights Reserved コードも 書くよ 2 -
ネクストビートと Scala • 創業13年、Scala で作り続けています ◦ 保育士バンク!の法人向け業務システム( toB SaaS) 図にないものも
◦ KIDSNA シッターのバックエンド( CtoC) Copyright © nextbeat All Rights Reserved 3 -
今日の話:認可の確認漏れをコンパイルエラーにする • テナント間のデータ分離について、 1つ1つのビジネスロジックに認可を入れるのではなく、システム のコア設計に認可機能を入れる • API やユースケースの実装ミスでデータがテナント外に漏れるのを防ぐ • 題材:法人向けの採用管理機能
Copyright © nextbeat All Rights Reserved 4 -
モチベーション 誰が書いても認可が守られる設計を作りたい API 確認あり API 確認あり API 確認あり API 確認あり
テスト / レビュー 1 つずつ読んで書き忘れを探す API 確認あり API 確認あり API 確認を書き忘れ API 確認あり 認可ができていることをコンパイル時に保証したい 静的補助ツールで補助したい Copyright © nextbeat All Rights Reserved 5 -
例:面接の一覧を返す API シンプルな実装 def listInterviews(user: User, applicationId: Application.Id) = for
_ <- checkTenant(user, applicationId) // 応募が自分のテナントのものか _ <- checkApplicationAccess(user, applicationId) // 自分がその応募を見られるか r <- interviewRepository.filterByApplicationId(applicationId) yield r Copyright © nextbeat All Rights Reserved 6 -
例:面接の一覧を返す API 確認を書き忘れてもコンパイルは通る def listInterviews(user: User, applicationId: Application.Id) = for
_ <- checkTenant(user, applicationId) // 応募が自分のテナントのものか _ <- checkApplicationAccess(user, applicationId) // 自分がその応募を見られるか r <- interviewRepository.filterByApplicationId(applicationId) yield r ハイライトした 2 行を消してもコンパイルは通る 漏れはレビューとテストで探すしかない Copyright © nextbeat All Rights Reserved 7 -
認可のレビュー漏れが起きると ... 書き忘れ GET /applications/<id>/interviews 確認を書き忘れた API ID だけで引く 他社の面接が
返る すり替え ?tenantId=<id> リクエストの tenantId で絞る WHERE tenant_id = ? 他社の応募が 返る ・書き忘れ :セキュリティの穴になる( IDOR) ・守りたいこと :どのクエリにもテナントの条件が付くこと。その値が ログインの結果(認証済みのユーザー)から来る Copyright © nextbeat All Rights Reserved こと 8 -
要件:テナント x 範囲 x リソース権限 でのチェック要件が多い テナント 企業 範囲 配属
メインリソース 応募 サブリソース 面接 データの会社がログインした人の会社と一致するか 所属チームとその配下に入っているか。見てよい範囲と書いてよい範囲は違う その応募を見てよいか(範囲の中か、自分が担当か) 親の応募を確かめたうえで面接がその親に属するか 段が増えるほど書き忘れる場所も増える Copyright © nextbeat All Rights Reserved 9 -
設計:テナント x 範囲 x リソース権限 の確認を値にする 確認を「呼ぶ処理」から「値」に変える。データを読む部品にはこの値を必ず渡させる。 これを「Evidence」という値にする。 Evidence 下の
3 つの総称。 authz パッケージの外では作れない。コンストラクタは private[authz] で、宣言場所は scalafix で検査する Principal テナント:「どの会社のどのチームの誰か」 Scope Principal が「見てよい範囲」と「書いてよい範囲」 配属や機能ごとに範囲が変わる リソース権限 各リソースにアクセスして良いか。面接は応募への権限がないと引けない Copyright © nextbeat All Rights Reserved 10 -
設計:流れ これまで: API ごとに確認を呼ぶ 今回:Evidence を渡す リクエスト リクエスト API 1
確認を呼ぶ 共通①:ログインから Principalを作る API 2 確認を書き忘れた API 1 共通②:認可処理で確認して Evidence を受け取る コンパイルが通る API 2 確認を書き忘れた × 渡す Evidenceがない Evidence データを読む部品 findById(id) データを読む部品 filterBy(access) ID だけで読める Evidenceが必須 他社の行も返る API 2 はコンパイルエラー Copyright © nextbeat All Rights Reserved 11 -
設計: Evidence を作れる場所を限定する 検査(scalafix のルール)とレビューで守る コアの外:コンパイラが守る 認証の境界 ログインから Principal を作る
Evidence UseCase・API authz package ビジネスロジック Principal と Evidence を作る 渡す 永続化層の実装 受け取った Evidence を WHERE に入れる Copyright © nextbeat All Rights Reserved 12 -
Evidence:Principal (テナント x 範囲 ) package example.authz final class Principal
private[authz] ( val userId: User.Id, val tenantId: Tenant.Id, val teams: Set[Team.Id] // 所属チームを配下まで展開した集合 ): def readScope: ReadScope = ReadScope(tenantId, teams, userId) def writeScope: WriteScope = WriteScope(tenantId, teams) object PrincipalService: def authenticate(store: IdentityStore)(login: VerifiedLogin): Either[AuthError, Principal] = store.findIdentity(login.tenantCode, login.loginCode) match case Some((user, teams)) => Right(Principal(user.id, user.tenantId, TeamTree.expand(user.assignedTeams, teams))) case None => Left(AuthError.Unauthenticated) Principal はログインユーザーから決まる Principal には、テナント情報、アクセス可能な配属の範囲を定義した PrincipalService は public だが、呼び出し元は scalafix のルールで認証の境界に絞る Copyright © nextbeat All Rights Reserved 13 -
Evidence: Read / Write final class ReadScope private[authz] ( val
tenantId: Tenant.Id, val teams: Set[Team.Id], val self: User.Id ) final class WriteScope private[authz] ( val tenantId: Tenant.Id, val teams: Set[Team.Id] ) (アプリケーションの要件による) 配属の範囲やロールによって、閲覧可能な権限、書き込み可能な権限を定義する 簡略化のため、ロールに関しては外している Copyright © nextbeat All Rights Reserved 14 -
Evidence を元に、応募情報を取得する ポート( domain) trait ApplicationRepository: def findById(scope: ReadScope, id:
Application.Id): Option[Application] def findByIdForUpdate(scope: WriteScope, id: Application.Id): Option[Locked[Application]] def update(target: Locked[Application], updated: Application): Boolean MySQL の実装( MySqlDatabase)が組み立てる条件 -- findById(scope: ReadScope, id) SELECT ... FROM applications WHERE tenant_id = :scope.tenantId AND id = ? AND (team_id IN (:scope.teams) OR assignee_id = :scope.self) -- findByIdForUpdate(scope: WriteScope, id) WHERE tenant_id = :scope.tenantId AND id = ? AND team_id IN (:scope.teams) FOR UPDATE 引数にテナント情報・配属情報(範囲)をReadScope / WriteScope として渡す Evidence の値をそのまま SQL 文に入れる Evidence を取らないメソッドは scalafix のルールで止める ※Evidence の値が SQL に入れているかどうかのチェックはルールで守れていないので、テストとレビューで守る Copyright © nextbeat All Rights Reserved 15 -
例:面接の取得には応募への Evidence が必要 final class ApplicationAccess private[authz] (val tenantId: Tenant.Id,
val applicationId: Application.Id) object ApplicationAuthz: def check(applications: ApplicationRepository)( principal: Principal, id: Application.Id): Option[ApplicationAccess] = applications.findById(principal.readScope, id).map(a => ApplicationAccess(a.tenantId, a.id)) trait InterviewRepository: def filterBy(application: ApplicationAccess): Seq[Interview] def findById(application: ApplicationAccess, id: Interview.Id): Option[Interview] UseCase(ListInterviewsUseCase) ApplicationAuthz.check(applications)(principal, applicationId) .map(interviews.filterBy) .toRight(ListInterviewsError.NotFound) Principal ApplicationAuthz.check // 応募を確かめて証拠を得る // その証拠で面接を引く ApplicationAccess InterviewRepository Repository method の引数としては、応募 IDではなく、応募へのアクセスの Evidence を定義 Copyright © nextbeat All Rights Reserved 16 -
エラーに落とす: Evidence の渡し忘れ、 Evidence の自作 デモコードの抜粋 // 正常系:認証で得た Principal から参照の範囲の証拠を導いて渡す
def ok(applicationRepository: ApplicationRepository, principal: Principal, id: Application.Id) = applicationRepository.findById(principal.readScope, id) // NG 1:証拠を渡さずに読もうとする def withoutEvidence(applicationRepository: ApplicationRepository, id: Application.Id) = applicationRepository.findById(id) // NG 2:テナントとチームを指定して、証拠を自分で作ろうとする def forgeEvidence(applicationRepository: ApplicationRepository, id: Application.Id) = val forged = new ReadScope(Tenant.Id("tenant-b"), Set(Team.Id("b-root")), User.Id("mallory")) applicationRepository.findById(forged, id) デモコードのコンパイル結果 [E007] Type Mismatch Error: Demo1NoEvidence.scala:15:26 | Found: (id : example.domain.Application.Id) | Required: example.authz.ReadScope [E173] Reference Error: Demo1NoEvidence.scala:19:21 | private[authz] constructor ReadScope can only be accessed from package example.authz ... Copyright © nextbeat All Rights Reserved 17 -
エラーに落とす: ReadScope での書き込み デモコードの抜粋 // 正常系:書き込みの範囲の証拠でロックし、ロックの証拠で更新する def ok(applicationRepository: ApplicationRepository, principal:
Principal, id: Application.Id) = applicationRepository.findByIdForUpdate(principal.writeScope, id) .exists(locked => applicationRepository.update(locked, locked.value.withStatus(Interviewing))) // NG 1:参照の範囲の証拠で書き込み用のロックを取ろうとする def lockWithReadScope(applicationRepository: ApplicationRepository, principal: Principal, id: Application.Id) = applicationRepository.findByIdForUpdate(principal.readScope, id) // NG 2:ロックせずに読んだ応募をそのまま更新しようとする def updateWithoutLock(applicationRepository: ApplicationRepository, principal: Principal, id: Application.Id) = applicationRepository.findById(principal.readScope, id) .map(application => applicationRepository.update(application, application.withStatus(Interviewing))) デモコードのコンパイル結果 [E007] Type Mismatch Error: Demo2ReadIsNotWrite.scala:17:45 | Found: example.authz.ReadScope | Required: example.authz.WriteScope [E007] Type Mismatch Error: Demo2ReadIsNotWrite.scala:23:46 | Found: (application : example.domain.Application) | Required: example.domain.Locked[example.domain.Application] Copyright © nextbeat All Rights Reserved 18 -
エラーに落とす:応募情報の権限がない場合に面接は取得不可 デモコードの抜粋 // 正常系:親の応募を参照できると確かめてから、その証拠で面接を引く def ok(applicationRepository: ApplicationRepository, interviewRepository: InterviewRepository, principal:
Principal, id: Application.Id) = ApplicationAuthz.check(applicationRepository)(principal, id).toSeq.flatMap(interviewRepository.filterBy) // NG 1:親の確認を書き忘れ、応募の ID で面接を引こうとする def withoutParentCheck(interviewRepository: InterviewRepository, id: Application.Id) = interviewRepository.filterBy(id) // NG 2:テナントとチームの範囲だけで、どの応募かを確かめずに面接を引こうとする def withScopeOnly(interviewRepository: InterviewRepository, principal: Principal) = interviewRepository.filterBy(principal.readScope) デモコードのコンパイル結果 [E007] Type Mismatch Error: Demo3SubResourceNeedsParent.scala:20:24 | Found: (id : example.domain.Application.Id) | Required: example.authz.ApplicationAccess [E007] Type Mismatch Error: Demo3SubResourceNeedsParent.scala:24:34 | Found: example.authz.ReadScope | Required: example.authz.ApplicationAccess Copyright © nextbeat All Rights Reserved 19 -
認可処理の閉じ込めは scalafix のルールで検査する デモコードの抜粋 class TrustBoundary extends SemanticRule("TrustBoundary") { private
val principalService = Symbol("example/authz/PrincipalService.") private val fromLockingRead = Symbol("example/domain/Locked.fromLockingRead().") override def fix(implicit doc: SemanticDocument): Patch = { // パッケージ名は外側のパッケージまで連結して判定する val packages = doc.tree.collect { case p: Pkg if !inAuthz && fullName(p).take(2) == List("example", "authz") => Patch.lint(...) } // 参照は SemanticDB のシンボルで判定する(コメントや文字列、別名の import に惑わされない) val references = doc.tree.collect { case n: Term.Name if n.symbol == principalService && !inServer && !inAuthz => Patch.lint(...) case n: Term.Name if n.symbol == fromLockingRead && !inInfra => Patch.lint(...) } (packages ++ references).asPatch } } ルール例 ・TrustBoundary:Evidence を発行できるパッケージを絞る ・RepositoryRequiresEvidence:名前が Repository で終わる trait のメソッドが証拠を引数に取ることを検査する Copyright © nextbeat All Rights Reserved 20 -
型で止まるもの、止まらないもの 止まるもの 止まらないもの ・Evidence の渡し忘れ ・Evidence の自作 ・ReadScope での書き込み ・応募情報の権限がない場合の面接取得
・WHERE の書き忘れ ・Scope を無視したクエリ ・権限から範囲を計算する処理の誤り ・抜け道(Java、検査対象外のプロジェクト、リ フレクション) Copyright © nextbeat All Rights Reserved 21 -
ご清聴ありがとうございました Scala で新規事業開発中! Scala エンジニア、大募集しています 引き続き Nextbeat TechBar を楽しんでいただけたらと思います 🍻
Copyright © nextbeat All Rights Reserved 22