Z-SOFT
エンジニアリング · セキュリティ

multi-tenant SaaS のセキュリティ:データはどこから漏れるのか

multi-tenant SaaS におけるデータ漏洩が、巧妙な攻撃から始まることはめったにありません。たいていは tenantId が欠けたクエリ、誤って cache された route、あるいは frontend ではボタンを隠したのに背後の API は開いたまま、といったところから起こります。この記事は、Z-EDU、Z-Auto、Z-Cloud Workspace で私たちが実際に行ったこと、そして誤りかけたことをまとめたものです。

Z-SOFT 開発チーム 10分で読めます

SaaS のセキュリティというと、多くの人は脆弱性を探る攻撃者を思い浮かべます。しかし、私たちが運用している multi-tenant(複数の企業が一つのシステムを共有し、データは完全に分離される)システムで、データが漏れかけた 原因は、たいてい非常にありふれたものでした。業務ロジックは正しく書けているのに WHERE tenantId の条件を忘れた、「速くするために」ある route の cache を有効にした、あるいは権限チェックがブラウザにしか 存在しなかった。高度な攻撃手法など一つも登場しません。問題は、システムのデフォルトが十分に安全でないことです。

この記事では、multi-tenant のデータが最も漏れやすい箇所を順に見ていきます。権限、cache、アップロード、 クライアントの時計、rate limit、そして認証基盤です。どれにもトレードオフがあります。「絶対に安全」と約束する のではなく、そのトレードオフを明示します。実際の運用にそんなものは存在しないからです。

1. 複数の企業:あらゆる制御はサーバー側になければならない

Z-Auto は自動車ショールームの管理プラットフォームです。各ショールームが一つの tenant であり、車両在庫、CRM の顧客、 予約金契約、試乗スケジュールをそれぞれ独立して持ちます。Z-Cloud Workspace も multi-tenant システムで、共通の 認証基盤を使う 11 個のアプリケーションで構成されています。どちらのシステムでも、tenant の条件が欠けたクエリが 一つあるだけで、ショールーム A のデータがショールーム B の画面に現れ得ます。

私たちがよく遭遇する、最も一般的な漏洩の原因は次の三つです。

ブラウザはユーザーの環境であって、あなたの環境ではありません。そこで動くもの — 権限チェック、ファイル種別の チェック、残り時間のカウントダウン — はすべて体験のためのヒントであり、セキュリティ制御ではありません。制御は、 あなたが管理するマシンの上で動いて初めて存在します。

私たちがすべての code review で繰り返している原則があります。お金、点数、アクセス権、あるいは他の tenant の データに影響する判断は、必ずサーバー側で計算し直さなければならない。クライアントがすでに計算していようが いまいが関係ありません。

2. 権限:唯一の信頼できる情報源

Z-EDU では、権限の一覧がすべて packages/config/src/permissions.ts に置かれています。pnpm monorepo の中で、 NestJS の backend と Next.js の frontend が共有する一つのファイルです。権限の定数はほかのどこにも散らばっていません。 理由は極めて実務的です。権限が二か所で定義されると、遅かれ早かれ食い違います。そして食い違ったとき、誤りは権限を 狭める側ではなく広げる側に傾きがちです。

backend は @RequirePermission@PermissionScope の decorator で遮断します。guard が metadata を読み、JWT/DB から取得したユーザーの実際の権限と突き合わせ、そして最も重要なこととして、データの スコープを tenant に束縛します。

// permissions.ts — 唯一の信頼できる情報源。BE と FE の両方で共有する
export const PERMISSIONS = {
  COURSE_UPDATE: 'course:update',
  ATTEMPT_READ:  'attempt:read',
} as const;

// courses.controller.ts
@Controller('courses')
export class CoursesController {
  @Patch(':id')
  @RequirePermission(PERMISSIONS.COURSE_UPDATE)
  @PermissionScope('tenant')            // guard は context に tenantId があることを必須にする
  async update(
    @CurrentUser() user: AuthUser,      // user.tenantId は token から取得。body からは取らない
    @Param('id') id: string,
    @Body() dto: UpdateCourseDto,
  ) {
    // tenantId の条件は where 句の中にある。読み出した後にチェックするのではない。
    // updateMany + count = 0 -> 存在しない、または この tenant のものではない:どちらも 404 を返す。
    const res = await this.prisma.course.updateMany({
      where: { id, tenantId: user.tenantId },
      data: dto,
    });
    if (res.count === 0) throw new NotFoundException();
    return this.prisma.course.findFirst({ where: { id, tenantId: user.tenantId } });
  }
}

注目すべき点が二つあります。第一に、tenantId は認証済みの token から取得し、body や query string からは 決して取りません。クライアントが tenantId を送れるなら、クライアントが tenant を選べてしまいます。 第二に、レコードが現在の tenant に属さない場合、私たちは 403 ではなく 404 を返します。 403 を返すと「この ID は存在するが、あなたには見せない」ことを意図せず認めてしまいます。 小さな漏洩経路ではあるものの、ID を総当たりで探るには十分です。

frontend も権限の一覧は受け取りますが、メニューやボタンの表示・非表示にしか使いません。それは 体験であって、セキュリティではありません。「契約を削除」ボタンを隠すのは、ユーザーが誤って 押さないようにするためです。誰かが DevTools を開き、JavaScript の bundle を読み、endpoint を見つけて直接呼ぶことは 止められません。API 自身が遮断しないのなら、隠されたボタンには何の防御価値もありません。

制御クライアント(UX のヒント)サーバー(本物の制御)
権限メニューやボタンの表示・非表示@RequirePermission の guard が request を遮断
tenant の分離役割はないtoken から得た tenantIdwhere に入れる
ファイルの種別 & サイズ早く知らせるため file input で絞り込むアップロード後の HeadObject
提出期限ユーザーに見せるカウントダウン開始時に確定した expiresAt と比較
採点、金額計算予定値の表示すべて計算し直し、クライアントの送る数値は無視
rate limitダブルクリックの抑止Redis で IP 単位に計数

3. cache:誰もコードに触れていないのにデータが漏れる場所

これは私たちにとって最も印象に残っている事故の一つです。業務ロジックからではなく、cache の設定から生まれました。

Z-EDU には三つの cache 層があります。最も外側が、公開 route に @HttpCache decorator で設定する Cache-Control です。CDN/Traefik が直接応答し、request は API にすら届きません。ここが負荷を受け止める 主役の層であり、その効果が大きいからこそ、私たちはかつてすべての @Public route に対して一気に 有効化しようとしたことがありました。

問題は、私たちのシステムにおける @Public が「JWT を必須にしない」という意味しか持たず、 「誰が見ても同じ」を意味しないことです。@Public の route の中には、URL に含まれる token や 受験セッションの文脈で保護された、ある個人だけのデータを返すものがあります。典型例が GET /exams/public/attempts/:id、つまり一人の受験者の解答です。この route に Cache-Control: public が付いていたら、proxy が応答を保持し、同じ URL にアクセスした次の人に 配信し得ます。業務コードが正しくても、データは漏れます。

// 誤り:@Public の route を全部さらって一律に cache を付ける
@Public()
@HttpCache({ public: true, maxAge: 60 })   // <-- 個人データを返す route なら大惨事
@Get('exams/public/attempts/:id')
getAttempt(@Param('id') id: string) { /* 一人の受験者の解答 */ }


// 正しい:route ごとに opt-in し、public と private を明確に区別する
@Public()
@HttpCache({ public: true, maxAge: 300 })  // 誰が見ても同じ内容
@Get('exams/public/:slug')
getExam(@Param('slug') slug: string) { /* 公開されている試験 */ }

@Public()
@NoStore()                                 // Cache-Control: no-store, private
@Get('exams/public/attempts/:id')
getAttempt(@Param('id') id: string) { /* デフォルト:cache しない */ }

きっぱりと切り分けるべき概念が二つあります。

Vary は補助的な道具です。応答がどの header に依存するかを cache に伝えるもので(たとえば Vary: AuthorizationVary: Accept-Language)、その header が異なる二つの request は 二つの異なる cache entry になります。しかし Vary を安全網とみなしてはいけません。付け忘れやすく、 token で区別されるデータが URL の中にあるなら Vary は何も救ってくれません。より頑健な原則は デフォルトでは cache せず、route ごとに opt-in することであり、@HttpCache を追加する人は 「この応答は匿名のユーザー全員にまったく同じか?」という問いに答えられなければなりません。少しでも迷うなら cache しないことです。

ここでのトレードオフは明白です。route ごとの opt-in は、いくつかの cache の機会を逃し、余分な負荷を引き受けること を意味します。それでも私たちはこのトレードオフを受け入れます。反対方向の代償は、ある受験者の解答を別の受験者に 配信することだからです。

4. ファイルのアップロード:クライアントの Content-Type を信用しない

Z-EDU では、ブラウザが presigned URL 経由でファイルを S3(RustFS、S3 互換)へ直接 PUT します。 API はバイト列の経路には入りません。URL に署名するだけであり、さらに重要なのは、登録の段階で 検証し直すことです。

なぜクライアントの送る Content-Type を信用しないのか。それは HTTP header の中の単なる文字列であり、 クライアントが自己申告するものだからです。誰でも Content-Type: image/png と宣言しながら中身を実行 ファイルにできますし、登録時に小さな Content-Length を申告してから 5GB のファイルをアップロードする こともできます。サイズとファイル種別は、すでに storage 上にある object から、つまりクライアントが書き換え られない情報源から読んで初めて意味を持ちます。それが HeadObject の役割です。

@Post('uploads/presign')
@RequirePermission(PERMISSIONS.FILE_UPLOAD)
async presign(@CurrentUser() user: AuthUser, @Body() dto: PresignDto) {
  const key = `t/${user.tenantId}/${randomUUID()}`;   // key には常に tenant の接頭辞を付ける
  const url = await this.s3.getSignedUrl('putObject', {
    Key: key, Expires: 300,                            // 署名付き URL の有効期限は 5 分
  });
  return { key, url };
}

@Post('uploads/commit')
@RequirePermission(PERMISSIONS.FILE_UPLOAD)
async commit(@CurrentUser() user: AuthUser, @Body() dto: CommitDto) {
  if (!dto.key.startsWith(`t/${user.tenantId}/`)) throw new ForbiddenException();

  // 真実はここにある:メタデータは STORAGE から読む。クライアントの申告からは読まない。
  const head = await this.s3.headObject({ Key: dto.key });

  if (head.ContentLength > MAX_SIZE)                  throw new BadRequestException('ファイルが大きすぎます');
  if (!ALLOWED_TYPES.includes(head.ContentType ?? '')) throw new BadRequestException('ファイル種別が不正です');

  return this.files.register({ key: dto.key, tenantId: user.tenantId, size: head.ContentLength });
}

クライアントが虚偽を申告した場合、object は S3 上に残るかもしれませんが、DB には登録されません。それは単なる ゴミファイルであり、定期的なクリーンアップジョブが後で削除します。攻撃者は自分の帯域を消費するだけで、システムは そのファイルを使う権利を与えません。

ダウンロード方向も同じです。bucket は公開していません。ファイルは短命の署名付き URL 経由で 配信され、その URL は、ユーザーがそのファイルを読む権限を持ち、かつファイルが正しい tenant に属することを API が確認したうえで発行されます。短命の URL は完璧なセキュリティではありません。有効期間の内は、URL を手に入れた 者は誰でもダウンロードできます。トレードオフは有効期限にあります。短すぎれば回線が遅いときに途中でリンクが切れ、 長すぎれば漏洩の窓が広がります。数分というのが、多くの場合において妥当な均衡点です。

注意点があります。HeadObject が読むのは S3 が保存した ContentType であり、この値の出所は やはりアップロード時です。登録の段階での虚偽申告は防げますが、ファイルの実際の内容を検査したことにはまだなりません。 より機微なデータであれば、次の層として magic bytes を読むか、worker でウイルススキャンを行うべきです。これはリスクの 度合いに応じた防御であり、単一のオン・オフのスイッチではありません。

5. クライアントの時計を信用しない

Z-EDU では、受験者がテストを開始した時点で、サーバーが expiresAt をその瞬間に確定し、受験レコードに 保存します。クライアントはその時刻を受け取り、ただ一つのことだけを行います。ユーザーに見せるためにそこへ向けて カウントダウンすることです。提出時、サーバーは request を受け取った時刻と保存済みの expiresAt を 比較します。クライアントが送ってくるどんな時刻フィールドも読みません。60 秒を超える遅れの提出は拒否されます。

なぜ 60 秒の猶予があるのか。クライアント端末の時計はずれ得ますし、ネットワークは遅くなり得ますし、request は いくつもの proxy 層を通るからです。ミリ秒単位で絶対的に拒否すれば、正直だが回線の悪い受験者が解答を失いかねません。 60 秒という数字は意図的なトレードオフです。悪用され得る小さな窓を受け入れる代わりに、本物のユーザーを不当に罰する ことを避けます。最も重要なのは、この判断がサーバーにあり、上限が定められ、測定できることです。ブラウザの Date.now() に依存してはいません。

さらに広げると、システム内のすべての時刻は UTC で保存し、表示するときと、授業スケジュールを生成したり報告期間を 締めたりするときにだけ UTC+7 に換算します。保存層で時間帯を混ぜるのはじわじわ効いてくるバグの温床であり、提出期限の 計算や帳簿の締めといった業務では、時間帯のバグはセキュリティホールと変わらない結果をもたらします。

一般的な原則:お金、点数、権限を決めるものはすべてサーバーで計算します。クライアントは結果を知ることは許されますが、 結果を決めることは許されません。

6. rate limit と fail-open の思想

Z-EDU は IP 単位で rate limit をかけ、Redis で計数します。Redis に障害が起きたとき、私たちは fail-open を選びます。request は遮断されず、そのまま通ります。理由は、ここでの rate limit が濫用を減らすためのものであり、 本人性を確認するためのものではないからです。fail-closed にすると、補助的な層の障害が API 全体に本物のユーザーを 拒否させかねません。この思想は Z-EDU のほかの部分にも現れます。Redis が壊れれば cache は DB へ素通りし(遅いが正しい)、 進捗の書き込みは DB への直接書き込みに切り替わります(動画の再生位置が数秒失われることはあっても、点数は失われません)。

async consume(ip: string): Promise<boolean> {
  try {
    const n = await this.redis.incr(`rl:${ip}`);
    if (n === 1) await this.redis.expire(`rl:${ip}`, WINDOW_SEC);
    return n <= LIMIT;
  } catch (err) {
    // Redis がダウン -> FAIL-OPEN。補助的な層のために API 自体を落とさない。
    this.logger.error({ err }, 'rate-limit backend down, failing open');
    this.metrics.increment('ratelimit.fail_open');   // この指標には必ずアラートを設定すること
    return true;
  }
}

しかし fail-open は意識的なトレードオフであり、無害なデフォルトではありません。濫用への耐性の一部を、 可用性と引き換えにしています。Redis が壊れている間、誰かが普段より多くの request を送れます。それでも受け入れるのは、 API が tenant 全体への提供を止める代償のほうが大きいからです。その埋め合わせが、fail-open の回数を計数し、 その指標にアラートを立てることです。危険なのは、静かに fail-open することのほうです。

境界は極めて明確です。性能を守る仕組みは fail-open、権限を決める仕組みは fail-closed。 権限を確認するサービスが応答できないなら、答えは「通す」ではなく「拒否」でなければなりません。決済ゲートウェイが 取引を確認できないなら、注文は保留の状態に留めるべきで、支払い済みと記録してはなりません。公開鍵を取得できず JWT を 検証できないなら、request は拒否しなければなりません。分類のための問いはこうです。 「この要素が沈黙したとき、本物のユーザーを誤って遮断するのと、権限のない者を誤って通すのとでは、どちらが悪いか?」 その答えが fail の向きを決めます。

7. 認証基盤の集約:一か所で締め、すべての権限を断つ

Z-Cloud Workspace には 11 個の統合アプリケーションがあります — chat、mail、docs、files、tasks、calendar、meetings、base、 workflow、board、contacts。もし各アプリケーションが個別にアカウントを管理していたら、従業員が退職したとき、IT チームは 11 か所でアカウントを止めることを覚えていなければなりません。現実には、少なくとも一か所は忘れます。そして忘れられた その一か所が、たいてい最も機微なデータを持つ場所です。

私たちは Keycloak/OIDC による SSO でこれを解決しています。すべてのアプリケーションに対する一度きりのログインであり、 さらに重要なのは、取り消しの場所が一つだけであることです。Keycloak でアカウントを無効化すれば、11 個の アプリすべてで権限が切れます。これは SSO を語るときに見落とされがちな利点です。人々は「一度ログインすれば済む」という 利便性ばかり口にしますが、より重要なセキュリティ上の価値は「一度で取り消せる」ことにあります。

トレードオフは、認証基盤の集約が共通の依存点を作ることです。Keycloak に障害が起きれば、新しいユーザーはログインできません。 その代わり、稼働中のセッションは token が期限切れになるまで動き続けます。したがって token の有効期限はよく検討すべき パラメータです。token が長ければ権限の取り消しに遅れが生じ、token が短ければシステムは IdP の可用性により強く依存します。 無料の選択肢はなく、リスクモデルに合う選択肢があるだけです。

Z-Auto の側では、各ショールームが subdomain 上に独自の storefront を持つか、独自ドメイン(custom domain)を向けられます。 これは注意すべき面を一つ増やします。tenant は host から決まるのです。host から tenant への解決はサーバー側で行い、 登録済みドメインの一覧から引かなければなりません。文字列を切り出して推測してはいけません。そして、公開ページ向けに host から tenantId を得たとしても、管理画面のデータは依然としてログインしたユーザーの token から tenantId を 取らなければなりません。この二つの情報源を混ぜてはいけません。Z-Cloud Workspace で API Gateway(OpenResty)の前に立つ CDN/WAF の層も同様の役割です。それはフィルタの層であり、権限の層ではありません。

最後に、Z-Cloud Workspace の chat は端末間で暗号化(E2E)されています。つまり運用者である私たちでさえ、内容を読めません。 これは代償のある設計上の選択です。サーバー側での検索ができなくなり、ユーザーが鍵を失ったときに復旧できなくなります。 それでも chat のチャネルについてはその代償を受け入れます。社内でやり取りされる内容のプライバシーのほうが価値があるからです。

8. multi-tenant セキュリティのチェックリスト

これは、multi-tenant システムで新しい endpoint をレビューするときに私たちが使っている一覧です。意図的に短く保っています。 長すぎるチェックリストは、たいてい最後まで読まれないからです。

結論

絶対に安全なシステムはありません。私たちが追い求めている、より現実的なことはこうです。 安全な選択をデフォルトにする。cache は初期状態でオフで、手動で有効にしなければならない。guard は スコープの宣言を必須にする。ファイルの metadata は常に storage から読み直す。時刻は常にサーバーで確定する。

multi-tenant SaaS におけるデータ漏洩は、たいてい見事な攻撃から生まれるものではないからです。それはたいてい、 優秀だが急いでいる開発者から、就業日の終わりに生まれます。そういうときにあなたを守ってくれるのは、システムの 安全なデフォルトです。

Z-SOFT はこのようなシステムを開発しています

本記事で紹介した技術は、すべて自社プロダクトの本番環境で実際に稼働しているものです。高負荷に耐え、セキュアで、 長期にわたって運用できるシステムをお考えでしたら、Z-SOFT の開発チームにぜひご相談ください。

関連記事