feat(meeting-intelligence): add per-meeting access control

Restrict MI data so only meeting attendees can access their meetings.
Each meeting gets a unique access_token generated at creation time.
Attendees discover their token via conference_id (room name) lookup.

Backend:
- New auth.py with Bearer token validation and multi-token extraction
- Token generation in create_meeting(), backfill on startup
- All endpoints gated: list_meetings filters by X-MI-Tokens header,
  per-meeting endpoints require Authorization: Bearer <token>
- New GET /meetings/token?conference_id=<room> discovery endpoint

Frontend:
- tokenStorage.ts manages tokens in localStorage keyed by room name
- middleware.ts auto-fetches token on CONFERENCE_JOINED
- All API calls in actions.ts now include auth headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-09 10:18:13 -04:00
parent 4efcdfbf36
commit 2fe77055a9
14 changed files with 441 additions and 34 deletions

View File

@ -0,0 +1,50 @@
"""
Authentication helpers for per-meeting access control.
Each meeting has a unique access_token. Knowing the conference_id (room name)
lets a client discover the token via GET /meetings/token?conference_id=<room>.
All other endpoints require a valid token to return data.
"""
from typing import List, Optional
from fastapi import HTTPException, Request
import structlog
log = structlog.get_logger()
def get_bearer_token(request: Request) -> Optional[str]:
"""Extract Bearer token from Authorization header."""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:]
return None
def get_multi_tokens(request: Request) -> List[str]:
"""Extract comma-separated tokens from X-MI-Tokens header."""
raw = request.headers.get("X-MI-Tokens", "")
if not raw:
return []
return [t.strip() for t in raw.split(",") if t.strip()]
async def validate_meeting_access(
request: Request,
meeting_id: str,
) -> None:
"""Verify the Bearer token matches the meeting's access_token.
Raises 403 if the token is missing or doesn't match.
"""
token = get_bearer_token(request)
if not token:
raise HTTPException(status_code=403, detail="Missing access token")
db = request.app.state.db
stored_token = await db.get_meeting_access_token(meeting_id)
if not stored_token or token != stored_token:
raise HTTPException(status_code=403, detail="Invalid access token")

View File

@ -3,6 +3,7 @@ Database operations for the Meeting Intelligence API.
"""
import json
import secrets
import uuid
from datetime import datetime
from typing import Optional, List, Dict, Any
@ -103,16 +104,18 @@ class Database:
) -> str:
"""Create a new meeting record."""
meeting_id = str(uuid.uuid4())
access_token = secrets.token_urlsafe(32)
async with self.pool.acquire() as conn:
await conn.execute("""
INSERT INTO meetings (
id, conference_id, conference_name, title,
recording_path, started_at, status, metadata
recording_path, started_at, status, access_token, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, 'recording', $7::jsonb)
VALUES ($1, $2, $3, $4, $5, $6, 'recording', $7, $8::jsonb)
""", meeting_id, conference_id, conference_name, title,
recording_path, started_at or datetime.utcnow(), json.dumps(metadata or {}))
recording_path, started_at or datetime.utcnow(),
access_token, json.dumps(metadata or {}))
return meeting_id
@ -148,6 +151,91 @@ class Database:
WHERE id = ${i}::uuid
""", *values)
async def list_meetings_by_tokens(
self,
tokens: List[str],
limit: int = 50,
offset: int = 0,
status: Optional[str] = None
) -> List[Dict[str, Any]]:
"""List meetings filtered by access tokens."""
if not tokens:
return []
async with self.pool.acquire() as conn:
if status:
rows = await conn.fetch("""
SELECT id, conference_id, conference_name, title,
started_at, ended_at, duration_seconds,
status, created_at
FROM meetings
WHERE access_token = ANY($1) AND status = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
""", tokens, status, limit, offset)
else:
rows = await conn.fetch("""
SELECT id, conference_id, conference_name, title,
started_at, ended_at, duration_seconds,
status, created_at
FROM meetings
WHERE access_token = ANY($1)
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""", tokens, limit, offset)
return [dict(row) for row in rows]
async def get_meeting_tokens_by_conference(
self,
conference_id: str
) -> List[Dict[str, Any]]:
"""Get access tokens for all meetings with a given conference_id."""
async with self.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, conference_id, access_token
FROM meetings
WHERE conference_id = $1 AND access_token IS NOT NULL
ORDER BY created_at DESC
""", conference_id)
return [dict(row) for row in rows]
async def get_meeting_access_token(
self,
meeting_id: str
) -> Optional[str]:
"""Get the access token for a specific meeting."""
async with self.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT access_token FROM meetings WHERE id = $1::uuid
""", meeting_id)
if row:
return row["access_token"]
return None
async def backfill_tokens(self):
"""Generate access tokens for meetings that don't have one."""
async with self.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id FROM meetings WHERE access_token IS NULL
""")
if not rows:
return 0
count = 0
for row in rows:
token = secrets.token_urlsafe(32)
await conn.execute("""
UPDATE meetings SET access_token = $1 WHERE id = $2
""", token, row["id"])
count += 1
log.info("Backfilled access tokens", count=count)
return count
# ==================== Transcripts ====================
async def get_transcript(

View File

@ -45,6 +45,12 @@ async def lifespan(app: FastAPI):
# Make database available to routes
app.state.db = state.db
# Backfill access tokens for any meetings created before auth was added
try:
await state.db.backfill_tokens()
except Exception as e:
log.warning("Token backfill failed (non-fatal)", error=str(e))
log.info("Meeting Intelligence API started successfully")
yield

View File

@ -14,6 +14,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from ..auth import validate_meeting_access
import structlog
log = structlog.get_logger()
@ -35,7 +37,9 @@ async def export_meeting(
include_transcript: bool = True,
include_summary: bool = True
):
"""Export meeting data in various formats."""
"""Export meeting data in various formats. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
# Get meeting data

View File

@ -7,6 +7,8 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel
from ..auth import get_bearer_token, get_multi_tokens, validate_meeting_access
import structlog
log = structlog.get_logger()
@ -36,6 +38,39 @@ class MeetingListResponse(BaseModel):
offset: int
class MeetingTokenResponse(BaseModel):
conference_id: str
meeting_id: str
access_token: str
@router.get("/token", response_model=List[MeetingTokenResponse])
async def get_meeting_token(
request: Request,
conference_id: str = Query(..., description="Room name to look up tokens for")
):
"""Get access tokens for meetings by conference_id (room name).
This is the only unauthenticated endpoint (besides /health).
Knowing the room name is proof of attendance.
"""
db = request.app.state.db
meetings = await db.get_meeting_tokens_by_conference(conference_id)
if not meetings:
raise HTTPException(status_code=404, detail="No meetings found for this conference")
return [
MeetingTokenResponse(
conference_id=m["conference_id"],
meeting_id=str(m["id"]),
access_token=m["access_token"]
)
for m in meetings
]
@router.get("", response_model=MeetingListResponse)
async def list_meetings(
request: Request,
@ -43,10 +78,20 @@ async def list_meetings(
offset: int = Query(default=0, ge=0),
status: Optional[str] = Query(default=None)
):
"""List all meetings with pagination."""
db = request.app.state.db
"""List meetings the caller has access to.
meetings = await db.list_meetings(limit=limit, offset=offset, status=status)
Requires X-MI-Tokens header with comma-separated access tokens.
Returns only meetings matching the provided tokens.
"""
db = request.app.state.db
tokens = get_multi_tokens(request)
if not tokens:
return MeetingListResponse(meetings=[], total=0, limit=limit, offset=offset)
meetings = await db.list_meetings_by_tokens(
tokens=tokens, limit=limit, offset=offset, status=status
)
return MeetingListResponse(
meetings=[
@ -63,7 +108,7 @@ async def list_meetings(
)
for m in meetings
],
total=len(meetings), # TODO: Add total count query
total=len(meetings),
limit=limit,
offset=offset
)
@ -71,9 +116,10 @@ async def list_meetings(
@router.get("/{meeting_id}", response_model=MeetingResponse)
async def get_meeting(request: Request, meeting_id: str):
"""Get meeting details."""
db = request.app.state.db
"""Get meeting details. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
meeting = await db.get_meeting(meeting_id)
if not meeting:
@ -97,16 +143,15 @@ async def get_meeting(request: Request, meeting_id: str):
@router.delete("/{meeting_id}")
async def delete_meeting(request: Request, meeting_id: str):
"""Delete a meeting and all associated data."""
db = request.app.state.db
"""Delete a meeting and all associated data. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
meeting = await db.get_meeting(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
# TODO: Implement cascade delete
# For now, just mark as deleted
await db.update_meeting(meeting_id, status="deleted")
return {"status": "deleted", "meeting_id": meeting_id}

View File

@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from ..auth import get_multi_tokens
from ..config import settings
import structlog
@ -58,6 +59,8 @@ class SearchRequest(BaseModel):
async def search_transcripts(request: Request, body: SearchRequest):
"""Search across meeting transcripts.
Requires X-MI-Tokens header. Results are filtered to authorized meetings only.
Search types:
- text: Full-text search using PostgreSQL ts_vector
- semantic: Semantic search using vector embeddings
@ -65,6 +68,13 @@ async def search_transcripts(request: Request, body: SearchRequest):
"""
db = request.app.state.db
# Get authorized meeting IDs from tokens
tokens = get_multi_tokens(request)
authorized_meetings = set()
if tokens:
meetings = await db.list_meetings_by_tokens(tokens=tokens, limit=1000)
authorized_meetings = {str(m["id"]) for m in meetings}
if not body.query or len(body.query.strip()) < 2:
raise HTTPException(
status_code=400,
@ -124,6 +134,12 @@ async def search_transcripts(request: Request, body: SearchRequest):
detail=f"Semantic search failed: {str(e)}"
)
# Filter to authorized meetings only
if authorized_meetings:
results = [r for r in results if r.meeting_id in authorized_meetings]
else:
results = []
# Deduplicate and sort by score
seen = set()
unique_results = []

View File

@ -10,6 +10,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
from pydantic import BaseModel
from ..auth import validate_meeting_access
from ..config import settings
import structlog
@ -116,7 +117,9 @@ def _summary_to_response(meeting_id: str, summary: dict) -> SummaryResponse:
@router.get("/{meeting_id}/summary", response_model=SummaryResponse)
async def get_summary(request: Request, meeting_id: str):
"""Get AI-generated summary for a meeting."""
"""Get AI-generated summary for a meeting. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
# Verify meeting exists
@ -142,7 +145,9 @@ async def generate_summary(
body: GenerateSummaryRequest,
background_tasks: BackgroundTasks
):
"""Generate AI summary for a meeting."""
"""Generate AI summary for a meeting. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
# Verify meeting exists

View File

@ -7,6 +7,8 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Request, Query
from pydantic import BaseModel
from ..auth import validate_meeting_access
import structlog
log = structlog.get_logger()
@ -53,7 +55,9 @@ async def get_transcript(
meeting_id: str,
speaker: Optional[str] = Query(default=None, description="Filter by speaker ID")
):
"""Get full transcript for a meeting."""
"""Get full transcript for a meeting. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
# Verify meeting exists
@ -96,7 +100,9 @@ async def get_transcript(
@router.get("/{meeting_id}/speakers", response_model=SpeakersResponse)
async def get_speakers(request: Request, meeting_id: str):
"""Get speaker statistics for a meeting."""
"""Get speaker statistics for a meeting. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
# Verify meeting exists
@ -123,7 +129,9 @@ async def get_speakers(request: Request, meeting_id: str):
@router.get("/{meeting_id}/transcript/text")
async def get_transcript_text(request: Request, meeting_id: str):
"""Get transcript as plain text."""
"""Get transcript as plain text. Requires Bearer token."""
await validate_meeting_access(request, meeting_id)
db = request.app.state.db
# Verify meeting exists

View File

@ -21,6 +21,7 @@ CREATE TABLE meetings (
status VARCHAR(50) DEFAULT 'recording',
-- Status: 'recording', 'extracting_audio', 'transcribing', 'diarizing', 'summarizing', 'ready', 'failed'
error_message TEXT,
access_token VARCHAR(64) UNIQUE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@ -30,6 +31,7 @@ CREATE INDEX idx_meetings_conference_id ON meetings(conference_id);
CREATE INDEX idx_meetings_status ON meetings(status);
CREATE INDEX idx_meetings_started_at ON meetings(started_at DESC);
CREATE INDEX idx_meetings_created_at ON meetings(created_at DESC);
CREATE INDEX idx_meetings_access_token ON meetings(access_token);
-- ============================================================
-- Meeting Participants

View File

@ -27,5 +27,6 @@ import '../face-landmarks/middleware';
import '../gifs/middleware';
import '../whiteboard/middleware.web';
import '../file-sharing/middleware.web';
import '../meeting-intelligence/middleware';
import './middlewares.any';

View File

@ -2,7 +2,7 @@
* Action creators for Meeting Intelligence feature.
*/
import { IStore } from '../app/types';
import { IReduxState, IStore } from '../app/types';
import {
CLEAR_SEARCH,
@ -34,6 +34,7 @@ import {
UPDATE_MEETING_STATUS
} from './actionTypes';
import { API_BASE_URL } from './constants';
import { getAllTokens, getTokenForConference, storeToken } from './tokenStorage';
import {
IMeeting,
IMeetingSummary,
@ -42,6 +43,43 @@ import {
ITranscriptSegment
} from './types';
/**
* Get a Bearer auth header for a meeting, looked up by conference_id from state.
*
* @param {Function} getState - Redux getState function.
* @param {string} meetingId - Optional meeting ID to look up conference_id.
* @returns {Record<string, string>} Headers object with Authorization if token found.
*/
function _getBearerHeaders(getState: () => IReduxState, meetingId?: string): Record<string, string> {
const state = getState();
const miState = state['features/meeting-intelligence'];
let conferenceId: string | undefined;
// Try to find conference_id from selected meeting or provided meetingId
if (miState?.selectedMeeting) {
conferenceId = miState.selectedMeeting.conference_id;
} else if (meetingId && miState?.meetings) {
const meeting = miState.meetings.find((m: IMeeting) => m.id === meetingId);
conferenceId = meeting?.conference_id;
}
// Fall back to current conference room name
if (!conferenceId) {
conferenceId = state['features/base/conference']?.room;
}
if (conferenceId) {
const token = getTokenForConference(conferenceId);
if (token) {
return { Authorization: `Bearer ${token}` };
}
}
return {};
}
/**
* Toggle the meeting intelligence dashboard.
*
@ -94,6 +132,37 @@ export function clearSelectedMeeting() {
};
}
/**
* Fetch access tokens for a conference_id and store them locally.
*
* @param {string} conferenceId - The room name.
* @returns {Function} Async thunk action.
*/
export function fetchMeetingToken(conferenceId: string) {
return async (_dispatch: IStore['dispatch']) => {
try {
const response = await fetch(
`${API_BASE_URL}/meetings/token?conference_id=${encodeURIComponent(conferenceId)}`
);
if (!response.ok) {
return;
}
const data = await response.json();
// Store each token keyed by conference_id
if (Array.isArray(data)) {
for (const entry of data) {
storeToken(entry.conference_id, entry.access_token);
}
}
} catch {
// Token fetch failures are non-fatal
}
};
}
/**
* Fetch the list of meetings.
*
@ -104,7 +173,14 @@ export function fetchMeetings() {
dispatch({ type: FETCH_MEETINGS_REQUEST });
try {
const response = await fetch(`${API_BASE_URL}/meetings`);
const tokens = getAllTokens();
const headers: Record<string, string> = {};
if (tokens.length > 0) {
headers['X-MI-Tokens'] = tokens.join(',');
}
const response = await fetch(`${API_BASE_URL}/meetings`, { headers });
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
@ -132,11 +208,13 @@ export function fetchMeetings() {
* @returns {Function} Async thunk action.
*/
export function fetchTranscript(meetingId: string) {
return async (dispatch: IStore['dispatch']) => {
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: FETCH_TRANSCRIPT_REQUEST });
try {
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`);
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/transcript`, {
headers: _getBearerHeaders(getState, meetingId)
});
if (!response.ok) {
if (response.status === 404) {
@ -172,11 +250,13 @@ export function fetchTranscript(meetingId: string) {
* @returns {Function} Async thunk action.
*/
export function fetchSummary(meetingId: string) {
return async (dispatch: IStore['dispatch']) => {
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: FETCH_SUMMARY_REQUEST });
try {
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`);
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, {
headers: _getBearerHeaders(getState, meetingId)
});
if (!response.ok) {
if (response.status === 404) {
@ -212,12 +292,13 @@ export function fetchSummary(meetingId: string) {
* @returns {Function} Async thunk action.
*/
export function generateSummary(meetingId: string) {
return async (dispatch: IStore['dispatch']) => {
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: GENERATE_SUMMARY_REQUEST });
try {
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/summary`, {
method: 'POST'
method: 'POST',
headers: _getBearerHeaders(getState, meetingId)
});
if (!response.ok) {
@ -246,9 +327,11 @@ export function generateSummary(meetingId: string) {
* @returns {Function} Async thunk action.
*/
export function fetchSpeakerStats(meetingId: string) {
return async (dispatch: IStore['dispatch']) => {
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
try {
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`);
const response = await fetch(`${API_BASE_URL}/meetings/${meetingId}/speakers`, {
headers: _getBearerHeaders(getState, meetingId)
});
if (!response.ok) {
return;
@ -285,9 +368,18 @@ export function searchTranscripts(query: string) {
dispatch({ type: SEARCH_REQUEST });
try {
const tokens = getAllTokens();
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (tokens.length > 0) {
headers['X-MI-Tokens'] = tokens.join(',');
}
const response = await fetch(`${API_BASE_URL}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify({ query, limit: 50 })
});
@ -340,12 +432,13 @@ export function setExportFormat(format: 'pdf' | 'markdown' | 'json') {
* @returns {Function} Async thunk action.
*/
export function exportMeeting(meetingId: string, format: string) {
return async (dispatch: IStore['dispatch']) => {
return async (dispatch: IStore['dispatch'], getState: () => IReduxState) => {
dispatch({ type: EXPORT_REQUEST });
try {
const response = await fetch(
`${API_BASE_URL}/meetings/${meetingId}/export?format=${format}`
`${API_BASE_URL}/meetings/${meetingId}/export?format=${format}`,
{ headers: _getBearerHeaders(getState, meetingId) }
);
if (!response.ok) {

View File

@ -51,8 +51,8 @@ const RecordingsList: React.FC = () => {
width = '48'>
<path d = 'M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z' />
</svg>
<h3>No recordings yet</h3>
<p>Recordings will appear here after you record a meeting.</p>
<h3>No recordings available</h3>
<p>Recordings will appear here after your meetings are processed.</p>
</div>
);
}

View File

@ -0,0 +1,25 @@
/**
* Middleware for Meeting Intelligence feature.
*
* Listens for CONFERENCE_JOINED to automatically fetch the access token
* for the current room, so it's ready when the user opens the MI dashboard.
*/
import { CONFERENCE_JOINED } from '../base/conference/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { fetchMeetingToken } from './actions';
MiddlewareRegistry.register(({ dispatch, getState }) => (next: Function) => (action: any) => {
const result = next(action);
if (action.type === CONFERENCE_JOINED) {
const room = getState()['features/base/conference']?.room;
if (room) {
dispatch(fetchMeetingToken(room) as any);
}
}
return result;
});

View File

@ -0,0 +1,64 @@
/**
* LocalStorage-based token storage for Meeting Intelligence access control.
*
* Tokens are keyed by conference_id (room name) so attendees can access
* meeting data across sessions without user accounts.
*/
const STORAGE_KEY = 'mi-tokens';
/**
* Get all stored tokens as a map of conferenceId -> token.
*
* @returns {Record<string, string>} The stored tokens.
*/
export function getStoredTokens(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
return JSON.parse(raw);
} catch {
return {};
}
}
/**
* Store a token for a conference.
*
* @param {string} conferenceId - The conference/room name.
* @param {string} token - The access token.
* @returns {void}
*/
export function storeToken(conferenceId: string, token: string): void {
try {
const tokens = getStoredTokens();
tokens[conferenceId] = token;
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
} catch {
// localStorage may be unavailable
}
}
/**
* Get a flat array of all stored token values.
*
* @returns {string[]} Array of token strings.
*/
export function getAllTokens(): string[] {
return Object.values(getStoredTokens());
}
/**
* Get the token for a specific conference.
*
* @param {string} conferenceId - The conference/room name.
* @returns {string|undefined} The token or undefined.
*/
export function getTokenForConference(conferenceId: string): string | undefined {
return getStoredTokens()[conferenceId];
}