Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions realtime/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ config는 cmd, internal/* 모두에서 import 가능
|----|--------|------|------|------|
| - | GET | `/health` | 헬스체크 | 활성 |
| RT2 | GET | `/realtime/stream/me` | user 채널 SSE (분석 상태). userId는 토큰에서 | 활성 |
| RT2 | GET | `/realtime/stream/documents/{id}` | document 채널 SSE | 활성 |
| RT2 | GET | `/realtime/stream/documents/{id}` | document 채널 SSE (DOCUMENT 스코프 토큰 필요 — 현재 발급처 없음) | 활성 |
| RT2 | GET | `/realtime/stream/sessions/{id}` | session 채널 SSE (feedback.ready 등 비-라이브) | 활성 |
| RT1 | WS | `/realtime/sessions/{id}` | 라이브 텍스트 면접 (서버→클라 push + 클라→서버 답변) | 활성 |
| RT3 | WS | `/realtime/sessions/{id}/audio` | 실시간 음성 답변 스트림 (오디오 업 ↔ 자막 다운, AI WS 프록시) | 활성 |
Expand Down Expand Up @@ -222,7 +222,15 @@ docker build -t stackup-realtime ./realtime
- AMQP `q.realtime.session.notify` consumer 활성 — `messageType` 기반 채널 라우팅 → fan-out
- WebSocket(RT1 라이브 면접) 활성 — `/realtime/sessions/{id}` 서버→클라 push + 클라→서버 답변 프록시(Core 내부 REST)
- WebSocket(RT3 실시간 음성) 활성 — `/realtime/sessions/{id}/audio` 브라우저↔AI WS 오디오 프록시(`WSAudioHandler`, `REALTIME_AI_WS_URL`). 오디오 전용 순수 파이프, STT·메트릭·`callback.voice`는 AI 책임
- 리소스 소유권 검증 미구현 — 현재 토큰 진위(userId)만 검증. 후속 플랜에서 리소스 스코프 토큰 또는 Core 조회로 강화
- **리소스 스코프 검증 활성** — 토큰 진위뿐 아니라 `resourceType`/`resourceId` 가 요청 경로의
대상과 일치하는지 확인한다(`transport/router.go`). RealTime 은 DB 를 보지 않으므로, Core 가
소유권을 확인해 발급한 토큰의 범위를 강제하는 것이 유일한 소유권 검사다.
- `sessions/{id}`(SSE·WS·audio) → `SESSION` + 같은 id
- `documents/{id}` → `DOCUMENT` + 같은 id. 이 채널로는 분석 요약·기술스택·문서 경로가
흐르므로(= 남의 이력서 내용) 검증 없이 열어두면 id 를 바꿔가며 긁을 수 있다.
현재 Core 는 DOCUMENT 스코프 토큰을 발급하지 않고 프론트도 이 채널을 쓰지 않는다
(분석 상태는 user 채널 `/realtime/stream/me` 로 받는다)
- `stream/me` → 경로에 id 가 없고 토큰의 `userId` 로 채널을 만든다(조작 여지 없음)
- DLQ 활성 — handler 실패 메시지는 `dlq.q.realtime.session.notify` 로 격리
- Prometheus 노출 미구현

Expand Down
15 changes: 13 additions & 2 deletions realtime/internal/transport/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ func NewRouter(sse *SSEHandler, ws *WSHandler, audio *WSAudioHandler, verifier *
}
sse.ServeChannel(w, req, session.Channel{Kind: session.ChannelUser, ID: c.UserID})
})
pr.Get("/realtime/stream/documents/{id}", channelByPath(sse, session.ChannelDocument)) // TODO: DOCUMENT 스코프 검증 (deferred)
// 이 채널로는 분석 결과(요약·기술스택·문서 경로)가 흐른다 — 남의 이력서 내용이다.
// 세션 채널과 같은 방식으로 토큰의 리소스 범위를 강제한다. 검증이 없으면 유효한
// 스트림 토큰을 가진 누구나 document id 를 바꿔가며 타인의 분석 결과를 받아볼 수 있다.
pr.Get("/realtime/stream/documents/{id}", scopedChannel(sse, session.ChannelDocument, "DOCUMENT"))
pr.Get("/realtime/stream/sessions/{id}", func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64)
if err != nil || id <= 0 {
Expand Down Expand Up @@ -76,13 +79,21 @@ func NewRouter(sse *SSEHandler, ws *WSHandler, audio *WSAudioHandler, verifier *
return r
}

func channelByPath(sse *SSEHandler, kind session.ChannelKind) http.HandlerFunc {
// scopedChannel serves an SSE channel only to tokens minted for that exact resource.
// Core issues the token after checking ownership, so matching the claim here is the
// only ownership check RealTime can make (it has no database access).
func scopedChannel(sse *SSEHandler, kind session.ChannelKind, resourceType string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64)
if err != nil || id <= 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
c, _ := auth.ClaimsFromContext(req.Context())
if c.ResourceType != resourceType || c.ResourceID != id {
http.Error(w, "token resource mismatch", http.StatusForbidden)
return
}
sse.ServeChannel(w, req, session.Channel{Kind: kind, ID: id})
}
}
Expand Down
89 changes: 89 additions & 0 deletions realtime/internal/transport/router_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package transport

import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/Team-StackUp/stackup/realtime/internal/auth"
"github.com/Team-StackUp/stackup/realtime/internal/session"
"github.com/go-chi/chi/v5"
)

// document 채널로는 분석 결과(요약·기술스택·문서 경로)가 흐른다 — 남의 이력서 내용이다.
// RealTime 은 DB 를 보지 않으므로, Core 가 소유권을 확인하고 발급한 토큰의 리소스 범위를
// 그대로 강제하는 것이 유일한 소유권 검사다.
func documentRequest(t *testing.T, id string, claims auth.Claims) *http.Request {
t.Helper()
req := httptest.NewRequest("GET", "/realtime/stream/documents/"+id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", id)
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = auth.WithClaims(ctx, claims)
return req.WithContext(ctx)
}

func newDocumentHandler() http.HandlerFunc {
return scopedChannel(NewSSEHandler(session.NewRegistry(), 4, time.Hour),
session.ChannelDocument, "DOCUMENT")
}

func TestDocumentChannelRejectsTokenForAnotherResourceType(t *testing.T) {
// 세션용 토큰으로 문서 채널을 구독하려는 경우 — 예전엔 그대로 통과했다.
req := documentRequest(t, "101", auth.Claims{UserID: 1, ResourceType: "SESSION", ResourceID: 101})
rec := httptest.NewRecorder()

newDocumentHandler()(rec, req)

if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for SESSION-scoped token, got %d", rec.Code)
}
}

func TestDocumentChannelRejectsTokenForAnotherDocument(t *testing.T) {
// 문서 101 토큰으로 문서 102 를 구독 — id 만 바꿔 남의 분석 결과를 긁는 경로다.
req := documentRequest(t, "102", auth.Claims{UserID: 1, ResourceType: "DOCUMENT", ResourceID: 101})
rec := httptest.NewRecorder()

newDocumentHandler()(rec, req)

if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for token of another document, got %d", rec.Code)
}
}

func TestDocumentChannelRejectsMissingClaims(t *testing.T) {
req := documentRequest(t, "101", auth.Claims{})
rec := httptest.NewRecorder()

newDocumentHandler()(rec, req)

if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 when claims are absent, got %d", rec.Code)
}
}

// 거부만 검증하면 비교를 뒤집어 놔도 통과한다 — 허용 경로도 확인한다.
func TestDocumentChannelAcceptsMatchingToken(t *testing.T) {
req := documentRequest(t, "101", auth.Claims{UserID: 1, ResourceType: "DOCUMENT", ResourceID: 101})
ctx, cancel := context.WithCancel(req.Context())
req = req.WithContext(ctx)
rec := httptest.NewRecorder()

done := make(chan struct{})
go func() {
newDocumentHandler()(rec, req)
close(done)
}()

// 스트리밍이 시작되므로 잠시 뒤 끊는다.
time.Sleep(50 * time.Millisecond)
cancel()
<-done

if rec.Code == http.StatusForbidden {
t.Fatalf("matching token must not be rejected, got %d", rec.Code)
}
}
Loading