v1
PoC / 設計素案

インタビュールーム 認可設計

「誰が・ルーム内で何をできるか」を、署名トークン由来の Context から導く単一ポリシーに集約する認可戦略。UI で隠すだけでなく、サーバー側ポリシーとメディアトークンの両方で物理強制する。

対象: インタビュールーム認可 独立アプリ(Next.js / TypeScript) 現状コード接地: minedia-www

登場人物

認可の主体と対象を整理する。マルチテナント(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 × resourceHTTP / チャット送信(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 を強制する」

公開API:テナントが API key で発行依頼 → role 割当を決定 ──封入署名──▶ インタビュールーム:参加者が JWT で入室 → role の能力を強制
🎯

本ページのスコープ = インタビュールームの認可。 role の能力定義はインタビュールーム側に一元化し、公開API側は「どの role を割り当ててよいか(API key の scope)」だけを持つ。両方に role ロジックを書くと二重管理=ドリフトの再来になる。

公開APIの認可(トークン発行スコープ・テナント管理APIの認可・アーカイブ事後アクセス)は別途の認可戦略として設計する。

🪪

軸の取り違えに注意:recording.access(事後の録画アクセス)は公開API側。主体が participant ではなく Operator/Employee(Devise)または ot_archive_access 共有リンク保持者で、保護対象も room 終了後のアーカイブ資源のため、後述の能力マトリクスには含めない。

§設計原則

インタビュールーム認可を貫く6原則。

  1. Deny by default — 明示的に許可された能力以外はすべて拒否。
  2. 署名トークン由来のみ — 認可は Context(tenant/room/participant/role)からのみ導く。クライアント申告のロールを一切信用しない。← 現状最大の脆弱点の解消。
  3. テナント=割当 / 基盤=強制(委譲認可)— 誰が moderator か はテナントが決める。moderator が何をできるか は基盤が強制。テナントは基盤が定義した能力を超えられない。
  4. 単一ポリシー(role, action, resource)→allow/deny を1モジュールに集約し、HTTP・チャット/データ送信・メディアトークン発行の全実行点で同じ関数を使う。
  5. 能力(capability)を下地にした RBAC — 固定ロールを露出しつつ内部実装は能力の集合にする(将来テナント独自ロール=能力の部分集合を後付け可能)。
  6. 多層防御 — UI で隠すだけでなくメディアトークンとサーバー側ポリシーの両方で強制(観察者の「カメラON」はボタンを隠すだけでなく canPublish:false で物理的に不可能にする)。

§ロール × 能力マトリクス

⚠️

2つの表を分ける。「現状コードの実態」と「新基盤の目標能力」は意図的に異なる(差分が移行で潰すドリフトそのもの)。以前の単一表は「目標」と「現状」を混同し、複数セルがコードと不一致だった。

不可 条件付き 閲覧のみ 受信は可・投稿は不可

現状コードの実態(minedia-www)

クライアント設定(env)の chat 設定・OpenTok token 発行・チャンネル振り分けを突合した現行の振る舞い。

能力moderatorpanelistobserverminedia_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 の公開チャット投稿可否を設計判断として明示する。

能力moderatorpanelistobserverminedia_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 を呼ぶのが肝。

① トークン発行 ② リゾルバ ③ アプリ HTTP API ④ チャット/データ送信 ⑤ メディアトークン発行
#場所チェック内容
トークン発行(公開API)API key が有効(=tenant 認証)かつ その tenant がその room を所有(L0/L1)。ここで role 割当をトークンに封入
リゾルバ RoomAccess.resolve署名 / exp / nbf / aud を検証し Context{tenant, room, participant, role} を生成(唯一の身元の源泉)。
アプリ HTTP APIRoomPolicy.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_observerobserver に統合するか / 統合しない場合の命名

ロール名のブランド依存(minedia 接頭辞)は OEM/ホワイトラベルでリークする。能力差は現状コードでは存在(minedia_observer のみ公開チャット投稿可)だが、目標では observer と同能力。これを踏まえ:

  • 案A(リネーム):別ロールのまま platform_observer 等へ改名。L0 例外はロール名キーで継続。
  • 案B(統合)observer 1 本に統合し、「テナント横断観察」は能力(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 スコープ認可)。