fix(voice): административный токен RoomService привязан к комнате
LiveKit принимает право `roomAdmin` только вместе с именем комнаты: без `video.room` RoomService отвечает 401 `permissions denied` — серверный мьют и отключение участника не доходили до SFU, хотя API отвечал 200 (AGENT.md 7.14). - `voice.IssueAdmin(room)` кладёт в грахт `room` и `roomAdmin` и отклоняет пустое имя комнаты; - `AdminClient.call` выпускает токен под конкретную комнату каждого вызова (GetParticipant, MutePublishedTrack, RemoveParticipant, ListParticipants); - тест на состав грахта и отказ при пустой комнате; - e2e: администратор и гость работают в отдельных api-контекстах (код 2FA одноразовый), нажатие «Войти» в комнату не ждёт стабильности исчезающей кнопки.
This commit is contained in:
+8
-12
@@ -53,7 +53,7 @@ func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string
|
||||
var response struct {
|
||||
Participant Participant `json:"participant"`
|
||||
}
|
||||
err := c.call(ctx, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response)
|
||||
err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,14 +78,14 @@ func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity str
|
||||
// Участник без микрофона: мьютить нечего, это не ошибка.
|
||||
return nil
|
||||
}
|
||||
return c.call(ctx, "MutePublishedTrack", map[string]any{
|
||||
return c.call(ctx, room, "MutePublishedTrack", map[string]any{
|
||||
"room": room, "identity": identity, "track_sid": trackSID, "muted": muted,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// RemoveParticipant удаляет участника из комнаты (кик из голосовой).
|
||||
func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error {
|
||||
return c.call(ctx, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil)
|
||||
return c.call(ctx, room, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil)
|
||||
}
|
||||
|
||||
// ListParticipants перечисляет участников комнаты: используется для проверки
|
||||
@@ -94,14 +94,15 @@ func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Part
|
||||
var response struct {
|
||||
Participants []Participant `json:"participants"`
|
||||
}
|
||||
if err := c.call(ctx, "ListParticipants", map[string]any{"room": room}, &response); err != nil {
|
||||
if err := c.call(ctx, room, "ListParticipants", map[string]any{"room": room}, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Participants, nil
|
||||
}
|
||||
|
||||
// call выполняет запрос к Twirp-методу RoomService с административным токеном.
|
||||
func (c *AdminClient) call(ctx context.Context, method string, body any, out any) error {
|
||||
// call выполняет запрос к Twirp-методу RoomService с административным токеном
|
||||
// этой комнаты: LiveKit принимает `roomAdmin` только вместе с именем комнаты.
|
||||
func (c *AdminClient) call(ctx context.Context, room, method string, body any, out any) error {
|
||||
if !c.Enabled() {
|
||||
return ErrDisabled
|
||||
}
|
||||
@@ -109,7 +110,7 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := c.adminToken()
|
||||
token, err := c.issuer.IssueAdmin(room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,8 +142,3 @@ func (c *AdminClient) call(ctx context.Context, method string, body any, out any
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminToken подписывает административный токен RoomService.
|
||||
func (c *AdminClient) adminToken() (string, error) {
|
||||
return c.issuer.IssueAdmin()
|
||||
}
|
||||
|
||||
+10
-8
@@ -120,12 +120,17 @@ func encodeSegment(payload []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
// IssueAdmin подписывает административный токен RoomService: полные права на
|
||||
// комнаты (модерация, удаление участников), но не на запись медиа.
|
||||
func (i *TokenIssuer) IssueAdmin() (string, error) {
|
||||
// IssueAdmin подписывает административный токен RoomService: права на
|
||||
// модерацию одной комнаты (мьют дорожки, удаление участника), но не на запись
|
||||
// медиа. LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room` в
|
||||
// грахте RoomService отвечает 401 `permissions denied`.
|
||||
func (i *TokenIssuer) IssueAdmin(room string) (string, error) {
|
||||
if !i.Enabled() {
|
||||
return "", ErrDisabled
|
||||
}
|
||||
if strings.TrimSpace(room) == "" {
|
||||
return "", fmt.Errorf("voice.invalid_room: пустое имя комнаты")
|
||||
}
|
||||
now := i.now().UTC()
|
||||
claims := map[string]any{
|
||||
"iss": i.apiKey,
|
||||
@@ -133,11 +138,8 @@ func (i *TokenIssuer) IssueAdmin() (string, error) {
|
||||
"nbf": now.Add(-10 * time.Second).Unix(),
|
||||
"exp": now.Add(10 * time.Minute).Unix(),
|
||||
"video": map[string]any{
|
||||
"roomCreate": true,
|
||||
"roomList": true,
|
||||
"roomAdmin": true,
|
||||
"roomRecord": false,
|
||||
"ingressAdmin": false,
|
||||
"room": room,
|
||||
"roomAdmin": true,
|
||||
},
|
||||
}
|
||||
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
|
||||
|
||||
@@ -61,6 +61,43 @@ func TestIssueTokenHasLiveKitClaims(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAdminTokenIsRoomScoped(t *testing.T) {
|
||||
issuer := voice.NewIssuer("key123", "secret456", time.Hour)
|
||||
token, err := issuer.IssueAdmin("guild_1_channel_2")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAdmin: %v", err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("JWT состоит из %d частей, ожидалось 3", len(parts))
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
var claims struct {
|
||||
Issuer string `json:"iss"`
|
||||
Video struct {
|
||||
Room string `json:"room"`
|
||||
RoomAdmin bool `json:"roomAdmin"`
|
||||
} `json:"video"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
t.Fatalf("decode claims: %v", err)
|
||||
}
|
||||
// LiveKit проверяет `roomAdmin` вместе с именем комнаты: без `room`
|
||||
// RoomService отвечает 401 `permissions denied` (проверено на 1.9.7).
|
||||
if claims.Issuer != "key123" || !claims.Video.RoomAdmin {
|
||||
t.Fatalf("административные права = %+v", claims)
|
||||
}
|
||||
if claims.Video.Room != "guild_1_channel_2" {
|
||||
t.Fatalf("комната в грахте = %q", claims.Video.Room)
|
||||
}
|
||||
if _, err := issuer.IssueAdmin(" "); err == nil {
|
||||
t.Fatal("пустая комната должна отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerDisabledWithoutKeys(t *testing.T) {
|
||||
issuer := voice.NewIssuer("", "", time.Hour)
|
||||
if issuer.Enabled() {
|
||||
|
||||
Reference in New Issue
Block a user