PII分離アーキテクチャ
Authrimは、個人識別情報(PII)と非PIIデータをデータベースレベルとアプリケーションレベルの両方で厳密に分離しています。このアーキテクチャにより、認証フローの高パフォーマンスを維持しながら、プライバシー規制(GDPR、CCPA、個人情報保護法)への準拠を実現します。
なぜ物理的にDBを分離するのか
テーブルレベルでの分離を使用する従来のアプローチとは異なり、Authrimは PIIを専用データベースに物理的に分離します:
- 監査・コンプライアンス: 「PIIは別DBに格納」はGDPR/CCPAの監査で「テーブルレベルで分離」より説得力がある
- リージョン展開: EU専用PII DBの追加は新しいD1バインディングを追加するだけ、スキーマ変更不要
- アクセス制御の強制:
PIIContextを持たないコードはPIIにアクセスできない - コードレビューで一目瞭然
アーキテクチャ概要
flowchart TB
subgraph APP["アプリケーション層 (Honoハンドラー, サービス, フロー)"]
A1["/authorize
Core+Cacheのみ"]
A2["/token
Core+Cacheのみ"]
A3["/userinfo
Core+Cache+PII"]
end
subgraph REPO["リポジトリ層"]
R1["CoreRepository
• UserCore
• Passkey
• Session
• Role
• Relationship
• OAuthClient"]
R2["PIIRepository
• UserProfile
• Identifiers
• LinkedIdentity
• AuditLog(PII)"]
R3["CacheRepository
• UserCache
• ConsentCache
• RBACCache
• ClientCache"]
end
subgraph ADAPTER["データベースアダプター層"]
AD1["D1Adapter"]
AD2["DOAdapter"]
AD3["KVAdapter"]
AD4["PGAdapter
(Regional)"]
end
subgraph STORAGE["ストレージ"]
DB1["グローバル非PII (D1/DO)
• users_core
• passkeys
• sessions
• roles
• relationships
• oauth_clients"]
DB2["グローバルキャッシュ (KV/DO)
• USER_CACHE
• REBAC_CACHE
• CONSENT_CACHE
• CLIENTS_CACHE"]
DB3["リージョナルPII (D1/Postgres)
EU: users_pii
JP: users_pii
US: users_pii
• identifiers
• linked_ids"]
end
APP --> REPO
REPO --> ADAPTER
AD1 --> DB1
AD2 --> DB1
AD3 --> DB2
AD4 --> DB3
三層リポジトリパターン
リポジトリは異なる特性を持つ3層に分割されます:
| 層 | 特性 | 用途 |
|---|---|---|
| CacheRepository | 最速・揮発性・安価 | UserInfo高速化、RBACクレームキャッシュ |
| CoreRepository | 耐久性・整合性・グローバル | 認証・認可の真実のソース |
| PIIRepository | 地域縛り・GDPR対応・暗号化 | 個人データのみ |
TypeScriptレベルでのアクセス制御
TypeScriptの型システムがコンパイル時にPII境界を強制します。
コンテキスト型
// PIIにアクセスできないContext(/authorize, /token用)interface AuthContext { core: CoreRepository; cache: CacheRepository; // piiプロパティなし → PIIにアクセス不可能}
// PIIにアクセスできるContext(/userinfo, /admin/users用)interface PIIContext extends AuthContext { pii: PIIRepository;}
// ハンドラー型定義type AuthHandler = (c: HonoContext, ctx: AuthContext) => Promise<Response>;type PIIHandler = (c: HonoContext, ctx: PIIContext) => Promise<Response>;コンパイル時の強制
// authorize.ts - AuthHandler型なのでctx.piiアクセスはコンパイルエラーexport const authorizeHandler: AuthHandler = async (c, ctx) => { const client = await ctx.cache.getCachedClient(clientId); // ✅ OK const session = await ctx.core.getSession(sessionId); // ✅ OK
// ❌ コンパイルエラー: プロパティ 'pii' は型 'AuthContext' に存在しません // const profile = await ctx.pii.getUserProfile(userId);
return c.redirect(redirectUri);};
// userinfo.ts - PIIHandler型なのでctx.piiにアクセス可能export const userinfoHandler: PIIHandler = async (c, ctx) => { const core = await ctx.core.getUserCore(userId); // ✅ OK const profile = await ctx.pii.getUserProfile(userId); // ✅ OK
return c.json({ sub: core.id, email: profile.email });};PII分類
PIIを含むテーブル(分離が必要)
| テーブル | PIIフィールド | リスクレベル |
|---|---|---|
users_pii | email, name, phone_number, address, birthdate | Critical |
user_custom_fields | field_value(任意のPIIが入る可能性) | High |
subject_identifiers | identifier_value(email, phone, DID) | High |
linked_identities | provider_email, raw_claims, profile_data | High |
audit_log_pii | ip_address, user_agent | Medium |
非PIIテーブル(分離不要)
| カテゴリ | テーブル | 説明 |
|---|---|---|
| 認証インフラ | passkeys, sessions, password_reset_tokens | 公開鍵、セッションID(UUID参照) |
| 認可/RBAC | roles, user_roles, relationships, organizations | ロール定義、UUID参照のみ |
| 設定 | oauth_clients, upstream_providers, scope_mappings | クライアント設定、IdP設定 |
トークンのPII分析
Authrimのトークンは PII を含まないように設計されています:
Access Token
const accessTokenClaims = { iss: env.ISSUER_URL, // ✅ 非PII sub: authCodeData.sub, // ✅ UUID(非PII) scope: authCodeData.scope, // ✅ 非PII authrim_roles: [...], // ✅ 非PII authrim_org_id: "...", // ✅ UUID(非PII)};結論: Access TokenにPIIは含まれない。subはUUID、email/nameはUserInfoエンドポイント経由でのみ取得。
データベーススキーマ
コアデータベース(グローバルD1)
CREATE TABLE users_core ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL DEFAULT 'default', pii_partition TEXT NOT NULL DEFAULT 'default', pii_status TEXT NOT NULL DEFAULT 'pending', -- pending/active/failed/none/deleted email_verified INTEGER DEFAULT 0, user_type TEXT NOT NULL DEFAULT 'end_user', user_version INTEGER NOT NULL DEFAULT 1, is_deleted INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);PIIデータベース(リージョナル)
CREATE TABLE users_pii ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL DEFAULT 'default', email_encrypted TEXT NOT NULL, email_blind_index TEXT NOT NULL, -- 検索可能暗号化用 name TEXT, phone_number_encrypted TEXT, address_encrypted TEXT, pii_class TEXT NOT NULL DEFAULT 'IDENTITY_CORE', anonymized_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE UNIQUE INDEX idx_users_pii_email ON users_pii(tenant_id, email_blind_index);Blind Index(検索可能暗号化)
暗号化されたPIIフィールドを検索可能にするため:
function createBlindIndex(value: string, masterIndexKey: string): string { const normalized = value.toLowerCase().trim(); return crypto.createHmac('sha256', masterIndexKey) .update(normalized) .digest('base64url');}
// 使用例: emailをインデックスに露出せずにユーザーを検索async function findUserByEmail(email: string): Promise<string | null> { const blindIndex = createBlindIndex(email, env.INDEX_KEY); const result = await piiDb.query( 'SELECT id FROM users_pii WHERE email_blind_index = ?', [blindIndex] ); return result?.id ?? null;}リージョン/パーティションルーティング
type Partition = 'default' | 'eu' | 'jp' | 'us' | string;
class PartitionRouter { async getPIIAdapter(userId: string): Promise<DatabaseAdapter> { // Core DBからユーザーのパーティションを取得 const user = await coreDb.queryOne<{ pii_partition: Partition }>( 'SELECT pii_partition FROM users_core WHERE id = ?', [userId] );
return this.getAdapterForPartition(user?.pii_partition || 'default'); }
getAdapterForPartition(partition: Partition): DatabaseAdapter { // 適切なリージョナルデータベースアダプターを返す switch (partition) { case 'eu': return new D1Adapter(env.DB_PII_EU); case 'jp': return new D1Adapter(env.DB_PII_JP); default: return new D1Adapter(env.DB_PII); } }}マルチテナント分離レベル
Authrimは3段階のテナント分離をサポート:
| レベル | 説明 | ユースケース |
|---|---|---|
| Row-level | WHERE tenant_id = ? | デフォルト、最もコスト効率が良い |
| Schema-level | tenant_abc.users_pii | エンタープライズ、個別バックアップ |
| Database-level | 専用DBインスタンス | 最大分離、リージョナルコンプライアンス |
class PIIRepository { static forTenant(tenantId: string, config: TenantConfig): PIIRepository { switch (config.isolation_level) { case 'database': return new PIIRepository(getDedicatedAdapter(tenantId)); case 'schema': return new PIIRepository(sharedDb, `tenant_${tenantId}`); default: return new PIIRepository(defaultDb, 'public', tenantId); } }}Circuit BreakerとGraceful Degradation
リージョナルPII DBが障害を起こしても認証フローは継続:
export const userinfoHandler: PIIHandler = async (c, ctx) => { // Coreは必須 const core = await ctx.core.getUserCore(userId); if (!core) return c.json({ error: 'invalid_token' }, 401);
// PIIはCircuit Breaker経由 const profile = await piiCircuitBreaker.execute( () => ctx.pii.getUserProfile(userId), () => Promise.resolve(null) // フォールバック: nullを返す );
// PIIが利用不可の場合はdegradedレスポンス return c.json({ sub: core.id, email: profile?.email ?? null, name: profile?.name ?? null, _degraded: profile === null, // クライアントにdegraded状態を通知 });};GDPRコンプライアンス
Soft Delete + 匿名化
async function deleteUser(userId: string, mode: 'hard_delete' | 'anonymize') { // 1. キャッシュを即時無効化 await cache.invalidateUser(userId);
// 2. PIIを処理 if (mode === 'hard_delete') { await pii.deleteUserProfile(userId); } else { await pii.anonymizeUserProfile(userId); // email → "deleted_{userId}@anonymized.local" // name, phone, address → NULL }
// 3. Coreでsoft delete(監査用に保持) await core.updateUserCore(userId, { is_deleted: true, deleted_at: Date.now() });
// 4. relationships, sessions, passkeysを削除 await core.deleteUserRelationships(userId); await core.deleteUserSessions(userId);}Tombstoneテーブル
CREATE TABLE users_pii_tombstone ( user_id TEXT PRIMARY KEY, email_blind_index TEXT, -- 再登録防止 deleted_by TEXT NOT NULL, deletion_reason TEXT, deleted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), retention_until TIMESTAMPTZ NOT NULL -- パージするタイミング);メリット
1. 規制コンプライアンス
- GDPR: DSARと消去権の容易な実装
- CCPA: 「販売禁止」要件への明確な分離
- データレジデンシー: PIIデータベースを特定リージョンにデプロイ可能
2. セキュリティ
- 攻撃対象の縮小: コア認証サービスはPIIにアクセスできない
- 最小権限の原則: PII専用エンドポイントのみが個人データにアクセス
- 監査証跡: すべてのPIIアクセスを個別にログ記録
3. パフォーマンス
- 小さいコアデータベース: 認証/認可のクエリが高速
- 安全なキャッシング: コアデータはグローバルに積極的にキャッシュ可能
- トークンにPIIなし: トークンはコンパクトでセキュア
ベストプラクティス
- データベース間でJOINしない -
Promise.allを使用して各データベースから個別にフェッチ - ユーザーIDをリンクとして使用 - データベース間で共有される唯一のフィールド
- すべてのPIIアクセスをログ記録 - すべてのPII操作に監査ログを実装
- pii_status状態機械を使用 - PII書き込み状態を追跡(pending/active/failed/none)
- regionよりpartitionを優先 - より柔軟なルーティング(テナント、プラン、セキュリティレベル)
- IPルーティングはデフォルトOFF - VPN/proxyによりIPはコンプライアンスで信頼できない