0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Route → Service → Prisma の 3 層と「Service 層を認可の単一源にする」設計

0
Posted at

この記事は約 6 分で読めます。

筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ

Next.js App Router でマルチテナント SaaS を作るとき、認可ロジックを どこに置くか で迷いますよね。本記事では、運用中の SaaS 「たすきば Knowledge Relay」 で採用した 「Route → Service → Prisma の 3 層 + Service 層を認可の単一源」 という設計判断を整理します。

サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ

なぜ Service 層を中核にしたか

たすきばはマルチテナント SaaS なので、テナント越境は絶対に防がねばならない (個人情報漏洩相当のリスク)。

検討した観点:

観点 内容
認可マトリクス 画面別 × 操作別の権限が詳細に定義されている
どの層で認可するか Middleware / API ルート / Service 層 / DB のどれを最後の砦にするか
テスト容易性 認可ロジックがどこにあれば単体テストで網羅できるか

結論として、Service 層に認可を集中 させました (ADR-0005)。


1. 二段階認可の構造

段階 何を検証するか 実装層
第一段階: テナント境界 ログインユーザの tenantId == リクエスト対象 entity の tenantId Service 層 (viewerTenantId 必須引数化)
第二段階: ロール認可 ユーザのロール (admin / PM / TL / member / viewer) でこの操作が許可されているか Service 層 (checkProjectPermission 等のヘルパー)

2. 各レイヤの責任

Middleware — 認証のみ

// middleware.ts
export const config = {
  matcher: ['/((?!api/auth|api/explicit-signout|_next/static|favicon.ico).*)'],
};

export default auth((req) => {
  if (!req.auth) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
});

認証のみ。認可はしない。理由:

理由 内容
Edge runtime 制約 Prisma で DB を引けない → 最新ロール参照不可
URL パターンマッチ 操作種別 × エンティティ種別の複雑な認可は表現しづらい

API Route — Service 呼び出し

// src/app/api/projects/[projectId]/route.ts
export async function GET(req: NextRequest, { params }: { params: { projectId: string } }) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }

  const project = await getProjectById(params.projectId, {
    viewerTenantId: session.user.tenantId,
    viewerUserId: session.user.id,
    viewerRole: session.user.role,
  });

  if (!project) {
    return NextResponse.json({ error: 'not_found' }, { status: 404 });
  }

  return NextResponse.json(project);
}

入力バリデーション (Zod) + Service 呼び出し。認可ロジックは持たない。

Service 層 — 認可の単一源

// src/services/project.service.ts
export async function getProjectById(
  id: string,
  context: {
    viewerTenantId: string;
    viewerUserId: string;
    viewerRole: Role;
  },
) {
  const project = await prisma.project.findFirst({
    where: {
      id,
      tenantId: context.viewerTenantId,  // 第一段階: テナント境界
      deletedAt: null,
    },
  });
  if (!project) return null;

  // 第二段階: ロール認可
  await assertProjectMember(project.id, context.viewerUserId);

  return project;
}

ここが認可の単一源。すべての認可ロジックを集約します。


3. なぜ Middleware で認可しないのか

Middleware で認可も行う案 (Alt-1) も検討。不採用理由:

不採用理由 内容
Edge runtime 制約 Prisma で DB を引けない、JWT claim でしかロールを持てない
表現力不足 URL パターンマッチでは操作 × エンティティの認可を表現しづらい

Middleware は 「ログイン済みでないと API を叩けない」という最低限の壁 だけを担当させます。


4. なぜ RLS にしないのか

PostgreSQL Row Level Security (Alt-2) も検討。不採用理由:

不採用理由 内容
Prisma 相性 ORM 層での抽象化が難しい
移行コスト AWS RDS 等への移行時に RLS の移植コスト
テスト容易性 DB セッションを毎テスト設定する必要

v1 では Service 層認可で十分。Phase 2 で二重防御として再検討予定。Service 層が認可の単一源のため、DB 側に RLS を段階導入しても影響範囲が局所化 できます。


5. super_admin の例外パス

テナント横断操作のため、第一段階のテナント境界チェックをスキップする例外パスを設けています。

export async function listAllTenantsForSuperAdmin(context: {
  viewerRole: Role;
}) {
  if (context.viewerRole !== 'super_admin') {
    throw new ForbiddenError('super_admin only');
  }
  // テナント境界をバイパス
  return prisma.tenant.findMany({ where: { deletedAt: null } });
}

ただし、super_admin 操作はすべて監査ログに記録 することで、事後検知できる状態にします。


6. 横展開漏れを防ぐレビュー観点

新規 Service 関数を追加するたび、以下を必ずレビュー。

□ 一覧系: viewerTenantId を必須引数にしているか
□ 詳細系: where 句に tenantId フィルタを入れているか
□ 削除系: where 句に tenantId フィルタを入れているか (super_admin 以外)
□ super_admin 操作の場合: 監査ログ記録があるか

これは CONTRIBUTING.md §5.2 にも明文化しています。


7. テスト容易性

Service 層に認可を集中させたことで、認可のテストは すべて単体テストで網羅 できます。

describe('getProjectById', () => {
  it('viewer の tenant とプロジェクトの tenant が一致しないとき null を返す', async () => {
    const tenantA = await createTenant();
    const tenantB = await createTenant();
    const project = await createProject({ tenantId: tenantA.id });

    const result = await getProjectById(project.id, {
      viewerTenantId: tenantB.id,  // 越境試行
      viewerUserId: 'any',
      viewerRole: 'admin',
    });

    expect(result).toBeNull();
  });
});

「越境試行が起きた場合に何が起きるか」を単体テストで証明 できます。これがテナント越境バグの再発防止の主軸。


おわりに

設計判断 効果
Service 層を認可の単一源にする どこを見れば認可が分かるか明確
viewerTenantId 必須引数化 越境バグを構造的に防ぐ
Middleware は認証のみ Edge runtime 制約に違反しない
RLS は採用しない (将来検討) Prisma との相性を優先
super_admin は監査ログで追跡 テナント境界バイパスを事後検知

このパターンは、Next.js App Router でマルチテナント SaaS を作る場合の、最小コストで最大の安全性を出す構成 だと考えています。

本記事の設計は、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?