🎄 科学と神々株式会社 アドベントカレンダー 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.