インタビュールーム 認可設計
「誰が・ルーム内で何をできるか」を、署名トークン由来の Context から導く単一ポリシーに集約する認可戦略。UI で隠すだけでなく、サーバー側ポリシーとメディアトークンの両方で物理強制する。
★登場人物
認可の主体と対象を整理する。マルチテナント(OEM)前提で「基盤」「テナント」「エンドユーザー」を分ける。
| 呼称 | 役割 |
|---|---|
| インタビュー基盤(新App) | プラットフォーム。トークン発行・検証・ルームランタイムを担う。本ページで「基盤」。 |
| テナント | 基盤の利用者。tenant 0 = minedia-www、tenant N = 他社(OEM)。 |
| エンドユーザー | パネリスト / モデレーター / オブザーバー(ブラウザ)。 |
§認可の層(L0–L4)
認証は「誰か」を1回確定すれば終わるが、認可は複数の境界で別々の判断になる。混ぜると認可ロジックが散在してドリフトする。層を分け、各層の判断を単一ポリシーに集約し、すべて署名トークン(Context)から導く。
| 層 | 何を認可するか | 何に基づくか | 強制ポイント |
|---|---|---|---|
| L0 テナント分離 | 他テナントのリソースに触れないこと | token.tenant == resource.tenant | 全層(最優先・水平分割) |
| L1 APIキー有効性 | テナント(機械)が発行してよいか(=有効な鍵か) | API key の有効性 | 公開API(トークン発行) |
| L2 ロール能力 | 参加者がルーム内で何ができるか | token.role | リゾルバ→各実行点 |
| L3 リソース・アクション | この資料/録画への個別操作 | role × resource | HTTP / チャット送信(Route Handler)/ メディア |
| L4 メディア権限 | カメラ発話 / 購読のみ | role → メディア grant | メディアエンジンのトークン |
§2つの認可:公開API と インタビュールーム
上の L0–L4 は、認証主体も保護対象も異なる2つの認可にまたがる。混同を避けるため別々に設計する。
| 公開API の認可 | インタビュールームの認可 | |
|---|---|---|
| 主体 | テナント(API key 保持者)。tenant 0 の人間UIは Operator/Employee の Devise | 参加者(署名済み participant JWT の Context{tenant, room, participant, role}) |
| 保護対象 | ① 参加トークン発行(誰を・どの room に・どの role で入れるか)② テナント向け管理API(room/slot/project の CRUD)③ アーカイブの取得・共有(録画の事後アクセス) | media publish/subscribe・chat・handout・recording.control・ban・invite = インタビュールーム内の振る舞い |
| 強制点 / 層 | L1(API key 有効性 + scope)+ L0(テナント所有突合)。人間UIは Devise + CanCanCan | L2/L3/L4(RoomPolicy.can)+ L0 |
両者の関係は 「公開API が role を決めてトークンに封入し、インタビュールームがその role を強制する」。
本ページのスコープ = インタビュールームの認可。 role の能力定義はインタビュールーム側に一元化し、公開API側は「どの role を割り当ててよいか(API key の scope)」だけを持つ。両方に role ロジックを書くと二重管理=ドリフトの再来になる。
公開APIの認可(トークン発行スコープ・テナント管理APIの認可・アーカイブ事後アクセス)は別途の認可戦略として設計する。
軸の取り違えに注意:recording.access(事後の録画アクセス)は公開API側。主体が participant ではなく Operator/Employee(Devise)または ot_archive_access 共有リンク保持者で、保護対象も room 終了後のアーカイブ資源のため、後述の能力マトリクスには含めない。
§設計原則
インタビュールーム認可を貫く6原則。
- Deny by default — 明示的に許可された能力以外はすべて拒否。
- 署名トークン由来のみ — 認可は Context(tenant/room/participant/role)からのみ導く。クライアント申告のロールを一切信用しない。← 現状最大の脆弱点の解消。
- テナント=割当 / 基盤=強制(委譲認可)— 誰が moderator か はテナントが決める。moderator が何をできるか は基盤が強制。テナントは基盤が定義した能力を超えられない。
- 単一ポリシー —
(role, action, resource)→allow/denyを1モジュールに集約し、HTTP・チャット/データ送信・メディアトークン発行の全実行点で同じ関数を使う。 - 能力(capability)を下地にした RBAC — 固定ロールを露出しつつ内部実装は能力の集合にする(将来テナント独自ロール=能力の部分集合を後付け可能)。
- 多層防御 — UI で隠すだけでなくメディアトークンとサーバー側ポリシーの両方で強制(観察者の「カメラON」はボタンを隠すだけでなく
canPublish:falseで物理的に不可能にする)。
§ロール × 能力マトリクス
2つの表を分ける。「現状コードの実態」と「新基盤の目標能力」は意図的に異なる(差分が移行で潰すドリフトそのもの)。以前の単一表は「目標」と「現状」を混同し、複数セルがコードと不一致だった。
現状コードの実態(minedia-www)
クライアント設定(env)の chat 設定・OpenTok token 発行・チャンネル振り分けを突合した現行の振る舞い。
| 能力 | moderator | panelist | observer | minedia_observer |
|---|---|---|---|---|
join(入室) | ✅ | ✅ | ✅ | ✅ |
media.publish(カメラ/マイク) | ✅ | ✅ | ✕* | ✕* |
media.subscribe(視聴) | ✅ | ✅ | ✅ | ✅ |
chat.public(全体・投稿) | ✅ | ✅ | 閲覧のみ | ✅ |
chat.private(運営間裏) | ✅ | ✕ | ✅ | ✅ |
chat.observer(観察者) | ✕ | ✕ | ✅ | ✅ |
handout.present(資料提示) | ✅ | ✕ | ✕ | ✕ |
handout.view | ✅ | ✅ | ✅ | ✅ |
recording.control(録画開始/停止) | ✅ | ✅† | ✅† | ✅† |
moderate(BAN/強制退出) | ✅ | ✕* | ✕* | ✕* |
invite(招待リンク発行) | ✅ | ✕ | ✅ | ✅ |
現状の能力差は「ほぼ全てクライアント強制」でサーバー未強制。 これが本設計が潰す最大の脆弱点。
* クライアント強制のみ:OpenTok token は observer 系も含め全員 :moderator(:publisher は panelist のみ)。observer の publish 不可・moderate 不可はクライアント設定だけで、メディア層では未強制(OpenTok 的には publish も forceMute/forceDisconnect も可能)。
・chat のサーバーガード不在:チャットチャンネルの送信に role チェックが無く、購読できれば誰でも投稿可。
† 録画:録画開始エンドポイントが Operator/Employee/Observer/User の全 namespace に存在。moderator 専用ではない。
・observer と minedia_observer は同能力ではない:minedia_observer は公開チャット投稿(+スタンプ)を追加で持つ。observer は公開チャット閲覧のみ。
新基盤の目標能力(あるべき姿)
移行後はこれを RoomPolicy でサーバー強制する。observer/minedia_observer の publish・moderate をメディアトークンで物理強制し、録画・招待を権限化し、両 observer の公開チャット投稿可否を設計判断として明示する。
| 能力 | moderator | panelist | observer | minedia_observer |
|---|---|---|---|---|
join(入室) | ✅ | ✅ | ✅ | ✅ |
media.publish(カメラ/マイク) | ✅ | ✅ | ✕ | ✕ |
media.subscribe(視聴) | ✅ | ✅ | ✅ | ✅ |
chat.public(全体チャット) | ✅ | ✅ | ✕ | ✕ |
chat.private(運営間裏チャット) | ✅ | ✕ | ✕ | ✕ |
chat.observer(観察者チャット) | ✅ | ✕ | ✅ | ✅ |
chat.read_all(全チャット閲覧) | ✅ | ✕ | △ | △ |
handout.present(資料提示) | ✅ | ✕ | ✕ | ✕ |
handout.view | ✅ | ✅ | ✅ | ✅ |
recording.control(録画開始/停止) | ✅ | ✕ | ✕ | ✕ |
moderate(BAN/強制ミュート) | ✅ | ✕ | ✕ | ✕ |
invite(招待リンク発行) | ✅ | ✕ | ✕ | ✕ |
§強制ポイント — どこで何をチェックするか
認可は5箇所で効く(独立アプリ・ActionCable 不使用)。各箇所が同じ RoomPolicy を呼ぶのが肝。
| # | 場所 | チェック内容 |
|---|---|---|
| ① | トークン発行(公開API) | API key が有効(=tenant 認証)かつ その tenant がその room を所有(L0/L1)。ここで role 割当をトークンに封入。 |
| ② | リゾルバ RoomAccess.resolve | 署名 / exp / nbf / aud を検証し Context{tenant, room, participant, role} を生成(唯一の身元の源泉)。 |
| ③ | アプリ HTTP API | RoomPolicy.can(ctx, 'recording_control') 等(資料提示・録画開始/停止・BAN)。※事後の録画アクセスは公開API側の別ドメイン。 |
| ④ | チャット/データ送信(Route Handler) | 送信時に投稿先への chat.* 能力を authz し、サーバー側が宛先(destination)を算出(canSendTo / destinationIdentitiesFor)。sender は Context の participant(クライアント申告の uuid/name を捨てる)。ActionCable の subscribe/perform は持たない。 |
| ⑤ | メディアトークン発行 | Context.role → grant 翻訳。observer は canPublish:false を物理強制。 |
⑤が特に重要:A/V の認可はアプリ層でなくメディアエンジンのトークン(grant)で強制する。観察者の「カメラON」はボタンを隠すだけでなく、トークンの canPublish:false で物理的に不可能にする。
§単一ポリシー(実装イメージ)
独立インタビューアプリ(Next.js / TypeScript)側の単一ポリシー。HTTP・チャット/データ送信・メディアトークン発行の全実行点がこの can を呼ぶ。能力定義は「目標能力」のプリセット。
// RoomPolicy — インタビュールーム認可の単一ポリシー type Role = 'moderator' | 'panelist' | 'observer' | 'minedia_observer'; type Capability = | 'join' | 'publish' | 'subscribe' | 'chat_public' | 'chat_private' | 'chat_observer' | 'chat_read_all' | 'chat_read_private' | 'handout_present' | 'handout_view' | 'recording_control' | 'moderate' | 'invite'; // recording_access(事後の録画アクセス)はここに含めない: // 入室ロールでなく公開API側の認可ドメイン(Devise+CanCanCan / ot_archive_access 共有リンク) const CAPABILITIES: Record<Role, Capability[]> = { moderator: ['join', 'publish', 'subscribe', 'chat_public', 'chat_private', 'chat_observer', 'chat_read_all', 'handout_present', 'recording_control', 'moderate', 'invite'], panelist: ['join', 'publish', 'subscribe', 'chat_public', 'handout_view'], observer: ['join', 'subscribe', 'chat_observer', 'chat_read_private', 'handout_view'], // テナント横断観察は L0 例外+監査。現状コードでは公開チャット投稿能力が追加で存在する点に注意 minedia_observer: ['join', 'subscribe', 'chat_observer', 'chat_read_private', 'handout_view'], }; export function can(ctx: Context, capability: Capability, resource?: { tenantId: string }): boolean { // L0: テナント分離(resource があれば必ず突合。minedia_observer のみ例外+監査) if (resource && resource.tenantId !== ctx.tenantId && ctx.role !== 'minedia_observer') return false; // L2/L3: 能力 return CAPABILITIES[ctx.role]?.includes(capability) ?? false; } // L4: メディアエンジン用 grant へ翻訳 export function mediaGrants(ctx: Context) { return { canPublish: can(ctx, 'publish'), canSubscribe: can(ctx, 'subscribe'), roomAdmin: can(ctx, 'moderate'), }; }
・HTTP・チャット/データ送信・メディア発行が全部この can を呼ぶ → 認可の真実が1箇所に。
・役割分担を混ぜない:RoomPolicy(独立アプリ・TypeScript)=インタビュールーム内のランタイム認可 / minedia-www 側の既存ドメイン認可(CanCanCan:Project/Answer/録画の事後アクセス=調査ドメイン)は minedia-www に残す(=公開API側)。
§role の責務分界 — 決定 / 封入署名 / 強制
「role を JWT に仕込むのは誰の責務か」を正確に分ける。
| 行為 | 責務 | Model A(集中発行・推奨) | Model B(自己署名) |
|---|---|---|---|
| role を決める(割当) | テナント | テナントが決める | テナントが決める |
| role を JWT に書いて署名 | — | 基盤(依頼を受けて) | テナント |
| role 値の妥当性検証(既知ロールか) | 基盤 | 発行時 | 検証時 |
| role が何をできるか強制 | 基盤 | ルーム実行点 | ルーム実行点 |
「2種類のクライアント」— ここが認可の急所
「クライアント申告を信用しない」の“クライアント”はエンドユーザー(ブラウザ)を指す。テナントサーバーの依頼とは別物。
| 主体 | role の主張を信用するか |
|---|---|
| エンドユーザー(ブラウザ) | 信用しない role をトークンに封じ込め、URL/param のロール申告を捨てる |
| テナントサーバー(API key 認証済み) | 信用する role 割当の権限を委譲(委譲信頼) |
「role はテナントが決めてよい」が「ブラウザが勝手に名乗るのはダメ」。Model A では、テナントの認証済み依頼を受けて基盤がトークンに封入・署名し、以後ブラウザはその署名済み role を改変できない。基盤は無検証で封入せず、role が既知の許可ロールかを検証する(未知ロールや不正値を弾く)。
role はトークン単位。scope は任意(機能制限ではない)
role は API キーに焼き込まない。role は「(参加者 × ルーム)」の属性で、参加トークン発行のたびに指定する。1 本の API キーが moderator も panelist も observer も全員分のトークンを発行する。
| API キー | トークンの role | |
|---|---|---|
| 主体 | テナント(サーバー) | 参加者(個人) |
| 寿命 / 個数 | 長命・テナントに数本 | 短命・入室ごとに 1 枚 |
| 役割 | テナントを認証(有効/無効) | この参加者の役割を選択 |
| 決まるタイミング | 発行時 | 参加トークン発行のたび(入室のたび) |
scope(API キーの権限範囲)は任意で、機能制限の手段ではない。 テナントは chat / media / 録画など全機能を使う前提。本質的な認可は L0 テナント分離とトークンの role で完結し、scope に依存しない。将来あり得る用途は機能制限ではなく鍵の最小権限分離(トークン発行専用キー vs 管理キー=漏洩時の被害局所化)。
§テナント分離(L0)の強制メカニクス
最も重大な認可境界。漏れたら全テナントのデータ流出なので多層で守る。
① トークン突合
リゾルバが token.tenant を Context に固定。全 resource アクセスで resource.tenant_id == ctx.tenant_id を確認(RoomPolicy.can に内蔵)。
② クエリスコープ
全集約ルート取得を where(tenant_id: ctx.tenant_id) で限定。
③ PoC でも check は入れる
tenant 0 しか無くても突合コードは最初から書く(後付けは漏れの温床)。
④ 将来の多層防御
DB の行レベルセキュリティ / default_scope は「実装は後、設計は今」。
§Open Questions — 要意思決定
現状整理が必要な意思決定事項。
1. minedia_observer を observer に統合するか / 統合しない場合の命名
ロール名のブランド依存(minedia 接頭辞)は OEM/ホワイトラベルでリークする。能力差は現状コードでは存在(minedia_observer のみ公開チャット投稿可)だが、目標では observer と同能力。これを踏まえ:
- 案A(リネーム):別ロールのまま
platform_observer等へ改名。L0 例外はロール名キーで継続。 - 案B(統合):
observer1 本に統合し、「テナント横断観察」は能力(role)でなくスコープ / Context 属性(例cross_tenant)で表現。L0 例外をロール名照合から外せる。 - 関連論点:テナント横断観察の監査ログ必須+テナント同意のガバナンス。L0 例外分岐は MT-ready のためコードは最初から書くが、PoC は tenant 0 のみで越境の発火経路なし。監査ログは契約予約のみ・実装は v2。
ステータス:チーム協議中。
2. 公開APIのスコープ
PoC では scope 不要(列だけ MT-ready 用に残す)。本番/MT での権限境界が未定:
- API key の scope で「割り当て可能 role の範囲」を縛るか。
- 鍵の最小権限分離(トークン発行専用キー vs 管理キー=漏洩時の被害局所化)。
- テナント管理API(room/slot/project CRUD)の認可境界。
recording.access(アーカイブ取得・共有)の認可主体:基盤かテナントか。録画の事後処理(文字起こし/要約)は調査ドメイン(minedia-www)。
ステータス:チーム協議中。
3. 時間窓ゲート
現状 can_enter_interview_room? の時間窓を、トークンの nbf / exp と二重化するか一本化するか。
将来検討(テナント#2〜・v2+):① カスタムロール(OEM テナントが「co-moderator=資料提示だけ可」等を能力の部分集合としてテナント設定で登録)② 動的ロール変更(観察者→登壇者の昇格時にトークン再発行か、ルーム内で grant 差し替えか=メディアトークン更新)③ ブレイクアウトルーム(1 Session 複数 Room の room スコープ認可)。