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?

Hybrid License System Day 16: Admin Serviceの設計

Last updated at Posted at 2025-12-15

🎄 科学と神々株式会社 アドベントカレンダー 2025

Hybrid License System Day 16: Admin Serviceの設計

Admin Service編 (1/5)


📖 はじめに

Day 16では、Admin Serviceの設計を学びます。管理機能の責務、React SPAダッシュボード、API設計、認可モデル(RBAC)について理解しましょう。


🎯 Admin Serviceの役割

主要な責務

1. ユーザー管理
   - ユーザーCRUD操作
   - プラン変更
   - アカウント停止/再開

2. ライセンス管理
   - ライセンス一覧表示
   - ライセンス無効化
   - 有効期限延長

3. 統計・分析
   - リアルタイム統計
   - アクティブユーザー数
   - プラン別収益分析

4. 監査ログ
   - 操作履歴表示
   - セキュリティイベント監視
   - ログ検索・フィルタリング

5. システム設定
   - レート制限設定
   - セキュリティポリシー
   - 通知設定

🏗️ アーキテクチャ設計

サービス構成

Admin Service
├── Backend (Express.js)
│   ├── API Server (Port 3002)
│   ├── DB Access (Shared SQLite)
│   ├── Auth Middleware (JWT検証)
│   └── RBAC Authorization
│
└── Frontend (React SPA)
    ├── Dashboard UI
    ├── User Management
    ├── License Management
    ├── Analytics Charts
    └── Audit Log Viewer

データフロー

1. 管理者ログイン
   Admin UI → API Gateway → Auth Service
   ↓
   JWT Token取得

2. ダッシュボード表示
   Admin UI → API Gateway → Admin Service
   ↓
   統計データ取得(JWT検証 + RBAC)

3. ユーザー管理操作
   Admin UI → API Gateway → Admin Service
   ↓
   データベース更新 + 監査ログ記録

🔐 認可モデル(RBAC)

ロール定義

// admin-service/src/rbac/roles.js

const ROLES = {
  SUPER_ADMIN: {
    name: 'super_admin',
    permissions: [
      'users.read',
      'users.write',
      'users.delete',
      'licenses.read',
      'licenses.write',
      'licenses.delete',
      'audit_logs.read',
      'settings.read',
      'settings.write',
      'analytics.read'
    ]
  },
  ADMIN: {
    name: 'admin',
    permissions: [
      'users.read',
      'users.write',
      'licenses.read',
      'licenses.write',
      'audit_logs.read',
      'analytics.read'
    ]
  },
  SUPPORT: {
    name: 'support',
    permissions: [
      'users.read',
      'licenses.read',
      'audit_logs.read'
    ]
  },
  VIEWER: {
    name: 'viewer',
    permissions: [
      'analytics.read'
    ]
  }
};

module.exports = ROLES;

権限チェックミドルウェア

// admin-service/src/middleware/rbac.js

class RBACMiddleware {
  constructor() {
    this.roles = require('../rbac/roles');
  }

  /**
   * 権限チェック
   * @param {string} requiredPermission - 必要な権限
   */
  requirePermission(requiredPermission) {
    return (req, res, next) => {
      // JWTからユーザー情報取得(API Gatewayで検証済み)
      const user = req.user;

      if (!user || !user.role) {
        return res.status(403).json({
          success: false,
          error: 'Forbidden',
          message: 'No role assigned'
        });
      }

      // ロール取得
      const roleConfig = Object.values(this.roles).find(r => r.name === user.role);

      if (!roleConfig) {
        return res.status(403).json({
          success: false,
          error: 'Forbidden',
          message: 'Invalid role'
        });
      }

      // 権限チェック
      if (!roleConfig.permissions.includes(requiredPermission)) {
        return res.status(403).json({
          success: false,
          error: 'Forbidden',
          message: `Permission denied: ${requiredPermission}`
        });
      }

      next();
    };
  }

  /**
   * 複数権限のいずれかが必要
   */
  requireAnyPermission(requiredPermissions) {
    return (req, res, next) => {
      const user = req.user;
      const roleConfig = Object.values(this.roles).find(r => r.name === user.role);

      if (!roleConfig) {
        return res.status(403).json({
          success: false,
          error: 'Forbidden',
          message: 'Invalid role'
        });
      }

      const hasPermission = requiredPermissions.some(perm =>
        roleConfig.permissions.includes(perm)
      );

      if (!hasPermission) {
        return res.status(403).json({
          success: false,
          error: 'Forbidden',
          message: 'Insufficient permissions'
        });
      }

      next();
    };
  }
}

module.exports = RBACMiddleware;

🛣️ API設計

RESTful API エンドポイント

// admin-service/src/routes/index.js
const express = require('express');
const router = express.Router();
const RBACMiddleware = require('../middleware/rbac');

const rbac = new RBACMiddleware();

// ===== ユーザー管理 =====
router.get('/users', rbac.requirePermission('users.read'), (req, res) => {
  // ユーザー一覧取得
});

router.get('/users/:userId', rbac.requirePermission('users.read'), (req, res) => {
  // ユーザー詳細取得
});

router.post('/users', rbac.requirePermission('users.write'), (req, res) => {
  // ユーザー作成
});

router.put('/users/:userId', rbac.requirePermission('users.write'), (req, res) => {
  // ユーザー更新
});

router.delete('/users/:userId', rbac.requirePermission('users.delete'), (req, res) => {
  // ユーザー削除
});

// ===== ライセンス管理 =====
router.get('/licenses', rbac.requirePermission('licenses.read'), (req, res) => {
  // ライセンス一覧取得
});

router.put('/licenses/:licenseId/status', rbac.requirePermission('licenses.write'), (req, res) => {
  // ライセンスステータス変更
});

router.put('/licenses/:licenseId/extend', rbac.requirePermission('licenses.write'), (req, res) => {
  // 有効期限延長
});

// ===== 統計・分析 =====
router.get('/analytics/overview', rbac.requirePermission('analytics.read'), (req, res) => {
  // 概要統計
});

router.get('/analytics/users', rbac.requirePermission('analytics.read'), (req, res) => {
  // ユーザー統計
});

router.get('/analytics/licenses', rbac.requirePermission('analytics.read'), (req, res) => {
  // ライセンス統計
});

// ===== 監査ログ =====
router.get('/audit-logs', rbac.requirePermission('audit_logs.read'), (req, res) => {
  // 監査ログ一覧
});

router.get('/audit-logs/search', rbac.requirePermission('audit_logs.read'), (req, res) => {
  // ログ検索
});

// ===== システム設定 =====
router.get('/settings', rbac.requirePermission('settings.read'), (req, res) => {
  // 設定取得
});

router.put('/settings', rbac.requirePermission('settings.write'), (req, res) => {
  // 設定更新
});

module.exports = router;

📊 データベース設計

管理者テーブル追加

-- 管理者テーブル
CREATE TABLE IF NOT EXISTS admins (
  admin_id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'viewer',
  created_at TEXT NOT NULL,
  last_login_at TEXT,
  CHECK(role IN ('super_admin', 'admin', 'support', 'viewer'))
);

-- 管理者セッションテーブル
CREATE TABLE IF NOT EXISTS admin_sessions (
  session_id TEXT PRIMARY KEY,
  admin_id TEXT NOT NULL,
  token TEXT UNIQUE NOT NULL,
  expires_at TEXT NOT NULL,
  created_at TEXT NOT NULL,
  FOREIGN KEY (admin_id) REFERENCES admins(admin_id) ON DELETE CASCADE
);

-- インデックス
CREATE INDEX IF NOT EXISTS idx_admins_email ON admins(email);
CREATE INDEX IF NOT EXISTS idx_admins_role ON admins(role);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_admin_id ON admin_sessions(admin_id);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_token ON admin_sessions(token);

AdminService用DBクラス

// admin-service/src/db/adminDB.js
const Database = require('better-sqlite3');

class AdminDB {
  constructor() {
    this.db = new Database(process.env.DB_PATH || './data/licenses.db');
    this.initialize();
  }

  initialize() {
    // 管理者テーブル作成
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS admins (
        admin_id TEXT PRIMARY KEY,
        email TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'viewer',
        created_at TEXT NOT NULL,
        last_login_at TEXT,
        CHECK(role IN ('super_admin', 'admin', 'support', 'viewer'))
      )
    `);

    // インデックス作成
    this.createIndexes();

    // デフォルト管理者作成
    this.createDefaultAdmin();
  }

  createIndexes() {
    this.db.exec('CREATE INDEX IF NOT EXISTS idx_admins_email ON admins(email)');
    this.db.exec('CREATE INDEX IF NOT EXISTS idx_admins_role ON admins(role)');
  }

  /**
   * デフォルト管理者作成(初回のみ)
   */
  createDefaultAdmin() {
    const existing = this.db.prepare('SELECT * FROM admins WHERE email = ?').get('admin@example.com');

    if (!existing) {
      const bcrypt = require('bcrypt');
      const passwordHash = bcrypt.hashSync('admin123', 10);
      const adminId = `admin-${Date.now()}`;
      const now = new Date().toISOString();

      this.db.prepare(`
        INSERT INTO admins (admin_id, email, password_hash, role, created_at)
        VALUES (?, ?, ?, ?, ?)
      `).run(adminId, 'admin@example.com', passwordHash, 'super_admin', now);

      console.log('Default admin created: admin@example.com / admin123');
    }
  }

  /**
   * 管理者取得
   */
  getAdminByEmail(email) {
    return this.db.prepare('SELECT * FROM admins WHERE email = ?').get(email);
  }

  /**
   * 全管理者取得
   */
  getAllAdmins() {
    return this.db.prepare('SELECT admin_id, email, role, created_at, last_login_at FROM admins').all();
  }

  /**
   * 最終ログイン時刻更新
   */
  updateLastLogin(adminId) {
    const now = new Date().toISOString();
    this.db.prepare('UPDATE admins SET last_login_at = ? WHERE admin_id = ?').run(now, adminId);
  }
}

module.exports = AdminDB;

🎨 React SPA設計

コンポーネント構成

admin-ui/
├── src/
│   ├── components/
│   │   ├── Dashboard/
│   │   │   ├── Overview.jsx
│   │   │   ├── StatCard.jsx
│   │   │   └── RealtimeChart.jsx
│   │   ├── Users/
│   │   │   ├── UserList.jsx
│   │   │   ├── UserDetail.jsx
│   │   │   └── UserForm.jsx
│   │   ├── Licenses/
│   │   │   ├── LicenseList.jsx
│   │   │   └── LicenseDetail.jsx
│   │   ├── AuditLogs/
│   │   │   ├── LogList.jsx
│   │   │   └── LogFilter.jsx
│   │   └── Common/
│   │       ├── Navbar.jsx
│   │       ├── Sidebar.jsx
│   │       └── Pagination.jsx
│   ├── contexts/
│   │   ├── AuthContext.jsx
│   │   └── ThemeContext.jsx
│   ├── hooks/
│   │   ├── useAuth.js
│   │   ├── useFetch.js
│   │   └── usePagination.js
│   ├── services/
│   │   ├── api.js
│   │   └── auth.js
│   └── App.jsx

AuthContext実装

// admin-ui/src/contexts/AuthContext.jsx
import React, { createContext, useState, useEffect } from 'react';
import { login as apiLogin, validateToken } from '../services/auth';

export const AuthContext = createContext();

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // トークンから認証状態復元
    const token = localStorage.getItem('admin_token');
    if (token) {
      validateToken(token)
        .then(userData => setUser(userData))
        .catch(() => localStorage.removeItem('admin_token'))
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const login = async (email, password) => {
    const { token, admin } = await apiLogin(email, password);
    localStorage.setItem('admin_token', token);
    setUser(admin);
  };

  const logout = () => {
    localStorage.removeItem('admin_token');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, login, logout, loading }}>
      {children}
    </AuthContext.Provider>
  );
};

🔒 セキュリティ考慮事項

CSRFトークン対策

// admin-service/src/middleware/csrf.js
const csrf = require('csurf');

const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  }
});

module.exports = csrfProtection;

XSS対策

// React側で自動エスケープ
// + DOMPurifyでサニタイゼーション
import DOMPurify from 'dompurify';

const SafeHTML = ({ html }) => {
  const sanitized = DOMPurify.sanitize(html);
  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
};

🎯 次のステップ

Day 17では、ユーザー管理API実装を学びます。CRUD操作、検索とフィルタリング、ページネーション、ソート機能について詳しく解説します。


🔗 関連リンク


次回予告: Day 17では、RESTful API実装とページネーション設計を詳しく解説します!


Copyright © 2025 Gods & Golem, Inc. All rights reserved.

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?