Private
Public Access
Phase 7: Bot/extension system
Bot accounts (User rows with is_bot=True), scoped API tokens (read:messages, write:messages, manage:rooms) authenticated via Authorization: Bearer on both REST and the WS handshake, live bot WebSocket access on the same /ws/chat endpoint humans use, message editing (WS "edit" envelope -> message_update broadcast, fans out cross-instance for free via the existing broadcaster), incoming webhooks (room-scoped, no auth beyond the URL token), and outgoing webhooks/event subscriptions (HMAC-SHA256 signed, backgrounded delivery, creation-time SSRF validation against private/loopback/link-local targets). Token auth is additive, not a parallel system: a bearer-token-authenticated bot goes through the exact same room-membership/role checks a session- authenticated human does everywhere; only read:messages/write:messages are separately scope-gated (the two message endpoints). manage:rooms scope enforcement, full per-delivery SSRF re-validation, and bot API rate limiting were explicitly scoped out (confirmed with the repo owner) as disproportionate to this phase -- documented as known gaps in backend/README.md rather than silently skipped. Admin portal gains a Bots tab (create bots, issue/revoke scoped tokens, cross-room webhook visibility); RoomInfoPanel gains room-scoped webhook/ subscription management, mirroring how invites already work there. The chat UI also gets a minimal "edit your own message" affordance -- not asked for by the issue, but the only practical way to exercise the edit pipeline by hand instead of only via a scripted bot client. Along the way: fixed a real bug caught while writing the incoming-webhook test -- offline-push notification relied on the sender being "connected" to exclude themselves, true for WS-originated messages but not for the new webhook path, which has no WS connection for the attributed sender at all. Now explicitly excluded. Also discovered the REST-only test fixture never triggered ASGI lifespan, so app.state.broadcaster/presence didn't exist for it; moved their construction out of the lifespan into create_app() itself (Redis client construction is synchronous/lazy) so both the WS and REST-only paths always have them. New tests/test_bots.py, test_message_edit.py, test_webhooks.py (full suite now 78/78, stable across repeated runs) plus a scripted end-to-end smoke test (bot WS join/post/edit, incoming webhook, SSRF rejection, outgoing delivery) and a full browser walkthrough of the new admin/room UI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,12 +14,12 @@ from app.schemas.admin import (
|
||||
ResetPasswordRequest,
|
||||
TransferOwnershipRequest,
|
||||
)
|
||||
from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
|
||||
from app.services.admin_service import (
|
||||
CannotActOnSelfError,
|
||||
RoomNotFoundError,
|
||||
TargetNotRoomMemberError,
|
||||
UserNotFoundError,
|
||||
list_audit_log,
|
||||
list_rooms_admin,
|
||||
list_users,
|
||||
reset_user_password,
|
||||
@@ -28,6 +28,11 @@ from app.services.admin_service import (
|
||||
set_user_site_admin,
|
||||
transfer_ownership_admin,
|
||||
)
|
||||
from app.services.audit import list_audit_log
|
||||
from app.services.webhook_service import (
|
||||
list_all_event_subscriptions_admin,
|
||||
list_all_incoming_webhooks_admin,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
@@ -223,3 +228,47 @@ async def list_audit_log_endpoint(
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
@router.get("/webhooks/incoming", response_model=list[WebhookIncomingAdminRead])
|
||||
async def list_incoming_webhooks_admin_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
webhooks = await list_all_incoming_webhooks_admin(db)
|
||||
return [
|
||||
WebhookIncomingAdminRead(
|
||||
id=w.id,
|
||||
room_id=w.room_id,
|
||||
token=w.token,
|
||||
created_by=w.created_by,
|
||||
description=w.description,
|
||||
created_at=w.created_at,
|
||||
room_name=w.room.name,
|
||||
created_by_username=w.creator.username,
|
||||
)
|
||||
for w in webhooks
|
||||
]
|
||||
|
||||
|
||||
@router.get("/event-subscriptions", response_model=list[EventSubscriptionAdminRead])
|
||||
async def list_event_subscriptions_admin_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
subscriptions = await list_all_event_subscriptions_admin(db)
|
||||
return [
|
||||
EventSubscriptionAdminRead(
|
||||
id=s.id,
|
||||
room_id=s.room_id,
|
||||
event_types=s.event_types,
|
||||
target_url=s.target_url,
|
||||
created_by=s.created_by,
|
||||
created_at=s.created_at,
|
||||
room_name=s.room.name if s.room else None,
|
||||
created_by_username=s.creator.username,
|
||||
)
|
||||
for s in subscriptions
|
||||
]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_site_admin
|
||||
from app.models import User
|
||||
from app.schemas.bot import ApiTokenCreate, ApiTokenCreated, ApiTokenRead, BotCreate, BotRead
|
||||
from app.services.bot_service import (
|
||||
BotNotFoundError,
|
||||
DuplicateBotError,
|
||||
InvalidScopeError,
|
||||
TokenNotFoundError,
|
||||
create_api_token,
|
||||
create_bot,
|
||||
list_api_tokens,
|
||||
list_bots,
|
||||
revoke_api_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin/bots", tags=["bots"])
|
||||
|
||||
|
||||
@router.post("", response_model=BotRead, status_code=201)
|
||||
async def create_bot_endpoint(
|
||||
data: BotCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
return await create_bot(db, current_user, data.username)
|
||||
except DuplicateBotError:
|
||||
raise HTTPException(status_code=409, detail="A user with this username already exists")
|
||||
|
||||
|
||||
@router.get("", response_model=list[BotRead])
|
||||
async def list_bots_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
return await list_bots(db)
|
||||
|
||||
|
||||
@router.post("/{bot_id}/tokens", response_model=ApiTokenCreated, status_code=201)
|
||||
async def create_token_endpoint(
|
||||
bot_id: uuid.UUID,
|
||||
data: ApiTokenCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
token, plaintext = await create_api_token(db, current_user, bot_id, data.scopes)
|
||||
except BotNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
except InvalidScopeError:
|
||||
raise HTTPException(status_code=400, detail="Unrecognized scope")
|
||||
return ApiTokenCreated(
|
||||
id=token.id,
|
||||
owner_id=token.owner_id,
|
||||
scopes=token.scopes,
|
||||
last_used_at=token.last_used_at,
|
||||
created_at=token.created_at,
|
||||
token=plaintext,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bot_id}/tokens", response_model=list[ApiTokenRead])
|
||||
async def list_tokens_endpoint(
|
||||
bot_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
return await list_api_tokens(db, bot_id)
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}", status_code=204)
|
||||
async def revoke_token_endpoint(
|
||||
token_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_site_admin(current_user)
|
||||
try:
|
||||
await revoke_api_token(db, current_user, token_id)
|
||||
except TokenNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
@@ -1,10 +1,15 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_room_member, require_room_role
|
||||
from app.dependencies import (
|
||||
get_current_user,
|
||||
require_room_member,
|
||||
require_room_role,
|
||||
require_scope,
|
||||
)
|
||||
from app.models import RoomRole, User
|
||||
from app.schemas.invite import InviteCreate, InviteRead
|
||||
from app.schemas.message import MessageRead
|
||||
@@ -28,6 +33,13 @@ from app.services.invite_service import (
|
||||
list_room_invites,
|
||||
revoke_invite,
|
||||
)
|
||||
from app.schemas.webhook import (
|
||||
EventSubscriptionCreate,
|
||||
EventSubscriptionCreated,
|
||||
EventSubscriptionRead,
|
||||
WebhookIncomingCreate,
|
||||
WebhookIncomingRead,
|
||||
)
|
||||
from app.services.message_service import list_recent_messages
|
||||
from app.services.room_service import (
|
||||
CannotRemoveOwnerError,
|
||||
@@ -50,6 +62,18 @@ from app.services.room_service import (
|
||||
transfer_ownership,
|
||||
update_room,
|
||||
)
|
||||
from app.services.webhook_service import (
|
||||
InvalidEventTypeError,
|
||||
SubscriptionNotFoundError,
|
||||
WebhookNotFoundError,
|
||||
create_event_subscription,
|
||||
create_incoming_webhook,
|
||||
list_event_subscriptions,
|
||||
list_incoming_webhooks,
|
||||
revoke_event_subscription,
|
||||
revoke_incoming_webhook,
|
||||
)
|
||||
from app.services.ssrf import UnsafeWebhookUrlError
|
||||
|
||||
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
|
||||
|
||||
@@ -252,10 +276,12 @@ async def transfer_ownership_endpoint(
|
||||
@router.get("/{room_id}/messages", response_model=list[MessageRead])
|
||||
async def get_room_messages_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
require_scope(request, "read:messages")
|
||||
await require_room_member(room_id, current_user, db)
|
||||
messages = await list_recent_messages(db, room_id, limit)
|
||||
return [
|
||||
@@ -266,6 +292,7 @@ async def get_room_messages_endpoint(
|
||||
username=m.user.username,
|
||||
content=m.content,
|
||||
created_at=m.created_at,
|
||||
edited_at=m.edited_at,
|
||||
)
|
||||
for m in messages
|
||||
]
|
||||
@@ -328,3 +355,93 @@ async def revoke_invite_endpoint(
|
||||
raise HTTPException(status_code=404, detail="Invite not found")
|
||||
except InviteNotPendingError:
|
||||
raise HTTPException(status_code=400, detail="Invite is no longer pending")
|
||||
|
||||
|
||||
@router.post("/{room_id}/webhooks/incoming", response_model=WebhookIncomingRead, status_code=201)
|
||||
async def create_incoming_webhook_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: WebhookIncomingCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await create_incoming_webhook(db, current_user, room_id, data.description)
|
||||
|
||||
|
||||
@router.get("/{room_id}/webhooks/incoming", response_model=list[WebhookIncomingRead])
|
||||
async def list_incoming_webhooks_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await list_incoming_webhooks(db, room_id)
|
||||
|
||||
|
||||
@router.delete("/{room_id}/webhooks/incoming/{webhook_id}", status_code=204)
|
||||
async def revoke_incoming_webhook_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
webhook_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
await revoke_incoming_webhook(db, room_id, webhook_id)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{room_id}/event-subscriptions", response_model=EventSubscriptionCreated, status_code=201
|
||||
)
|
||||
async def create_event_subscription_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
data: EventSubscriptionCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
subscription, secret = await create_event_subscription(
|
||||
db, current_user, room_id, data.event_types, data.target_url
|
||||
)
|
||||
except InvalidEventTypeError:
|
||||
raise HTTPException(status_code=400, detail="Unrecognized event type")
|
||||
except UnsafeWebhookUrlError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="target_url is not allowed (internal/private address)"
|
||||
)
|
||||
return EventSubscriptionCreated(
|
||||
id=subscription.id,
|
||||
room_id=subscription.room_id,
|
||||
event_types=subscription.event_types,
|
||||
target_url=subscription.target_url,
|
||||
created_by=subscription.created_by,
|
||||
created_at=subscription.created_at,
|
||||
signing_secret=secret,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{room_id}/event-subscriptions", response_model=list[EventSubscriptionRead])
|
||||
async def list_event_subscriptions_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
return await list_event_subscriptions(db, room_id)
|
||||
|
||||
|
||||
@router.delete("/{room_id}/event-subscriptions/{subscription_id}", status_code=204)
|
||||
async def revoke_event_subscription_endpoint(
|
||||
room_id: uuid.UUID,
|
||||
subscription_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await require_room_role(room_id, current_user, db, RoomRole.admin)
|
||||
try:
|
||||
await revoke_event_subscription(db, room_id, subscription_id)
|
||||
except SubscriptionNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Event subscription not found")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.webhook import IncomingWebhookPost
|
||||
from app.services.message_events import broadcast_new_message
|
||||
from app.services.webhook_service import WebhookNotFoundError, post_via_webhook
|
||||
|
||||
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
||||
|
||||
|
||||
@router.post("/incoming/{token}", status_code=204)
|
||||
async def incoming_webhook_endpoint(
|
||||
token: str,
|
||||
data: IncomingWebhookPost,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# No auth dependency at all -- the token in the URL is the credential,
|
||||
# per ARCHITECTURE.md's incoming-webhook design.
|
||||
try:
|
||||
message, room, sender = await post_via_webhook(db, token, data.content)
|
||||
except WebhookNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Unknown webhook")
|
||||
|
||||
broadcaster = request.app.state.broadcaster
|
||||
presence = request.app.state.presence
|
||||
await broadcast_new_message(db, broadcaster, presence, room.id, message, sender)
|
||||
Reference in New Issue
Block a user