背景:auth.go 注释写着 RoleViewer 是只读监控档,但实现从未接线 —— 管理侧 24 个 GET 全是 owner/admin,viewer 全 403。本批把它接上(手机端「全功能管理」的前置之一)。
- routes.go:新增第四道门 viewerAuth = Authorize(owner, admin, viewer);registerAdminRoutes 传入。**只**把 12 条只读监控 GET 从 writeAuth 换过来:personas / sessions / plugins / plugins-installed / channels / iot / automations / rag / observability{metrics,trace,logs} / notifications;其余 12 条读与 32 条写**一行不动**
- 保持 owner/admin(viewer 403):memories / llm / users / backthink / audit / sessions-export / channels-messages ×2;owner-only(35 条)含凭据获取(login-qr/login-status)、channels view/friends、全部写操作
- 三处「编码旧策略」的既有测试按**新权限矩阵**改写(不是放宽):gateway_test 的 /admin/plugins viewer 403→200 且补一条 memories 403;observability_logs 的 ViewerForbidden→ViewerReadsTail;notifications 的 viewer 403→200
- 新增 rbac_viewer_test.go:5 个顶层 / 185 个子测试(12 条×3 角色 200、9+35 条 viewer 403、admin 在读档非 401/403 且在 owner-only 403、56 条匿名 401、写路由 viewer 403);含 3 次变异验证(含**反向变异**:把 memories 误放开为 viewerAuth 会 FAIL)
- auth.go 的 RoleViewer 注释改为**实际**可见面;Docs/api-client 新增 §2.0 管理侧权限矩阵 + 变更记录 v0.40;顺带修 §0 一处不实陈述(原写「管理端另支持 X-API-Key」→ 标注尚未接线)
- 验收:**隔离 worktree(HEAD + 本片)** go build/vet/test 全绿 + linux/amd64 与 darwin/arm64 交叉编译 + gofmt 干净(主工作树此时被另一代理的在途改动占用,见下条提交)
- 已知遗留(未改前端,按范围只记录):管理面板未按角色隐藏分区 → viewer 打开 memories/users/llm/审计等页会吃 403(应做能力降级)
229 lines
14 KiB
Go
229 lines
14 KiB
Go
package gateway
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
)
|
|
|
|
// This file owns the HTTP surface: the route table (REST /api/v1, the realtime
|
|
// WebSocket hub, /api/v1/admin/* and externally-mounted webhook callbacks) and
|
|
// the outer middleware chain. Route registration is split by audience so the
|
|
// RBAC gate guarding each group stays visible: public, authenticated user,
|
|
// private history, admin reads and owner-only writes.
|
|
|
|
// Handler returns the fully assembled http.Handler: the ServeMux routing
|
|
// REST + WS + admin, wrapped in recovery, CORS and request logging.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// The RBAC middlewares are built once per role set and shared by every route
|
|
// that requires it (owner>admin>viewer, DevDocs/02 §九). viewerAuth is "every
|
|
// authenticated role": it names the read-only monitoring surface the viewer
|
|
// role exists for (module inventories, runtime metrics/traces, the log tail
|
|
// and the notification feed) — see registerAdminRoutes.
|
|
anyAuth := s.auth.Authorize()
|
|
ownerAuth := s.auth.Authorize(auth.RoleOwner)
|
|
writeAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)
|
|
viewerAuth := s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer)
|
|
|
|
s.registerPublicRoutes(mux)
|
|
s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth)
|
|
s.registerHistoryRoutes(mux, writeAuth)
|
|
s.registerAdminRoutes(mux, ownerAuth, writeAuth, viewerAuth)
|
|
s.mountWebhooks(mux)
|
|
|
|
return s.withMiddleware(mux)
|
|
}
|
|
|
|
// registerPublicRoutes mounts the unauthenticated surface: liveness plus the
|
|
// credential endpoints. Refresh/logout are public by design — the opaque refresh
|
|
// token is itself the credential, so a client with an expired access token can
|
|
// still rotate or revoke it. Both fail closed with 401 invalid_grant on a bad
|
|
// token.
|
|
func (s *Server) registerPublicRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
|
|
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
|
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
|
|
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
|
}
|
|
|
|
// registerUserRoutes mounts the authenticated user-facing surface. Chat is a
|
|
// write (owner/admin); reads accept any authenticated role so a viewer can still
|
|
// list personas/sessions. Audio STT/TTS and image upload are owner/admin-only (the
|
|
// owner's private assistant, audited) and are the REST surface native clients use
|
|
// to bypass the 4KB WebSocket frame limit.
|
|
func (s *Server) registerUserRoutes(
|
|
mux *http.ServeMux,
|
|
anyAuth func(http.Handler) http.Handler,
|
|
ownerAuth func(http.Handler) http.Handler,
|
|
writeAuth func(http.Handler) http.Handler,
|
|
) {
|
|
mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat)))
|
|
mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions)))
|
|
mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas)))
|
|
// Shared client enum tables (Android collaboration B4, design/07 §6): the
|
|
// authority every client maps ids to labels from, so a source badge or a mood
|
|
// label cannot drift per client. A read-only projection of
|
|
// internal/contract/enums with nothing sensitive in it, so any authenticated
|
|
// role may read it (same gate as /sessions above).
|
|
mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums)))
|
|
|
|
mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe)))
|
|
mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech)))
|
|
mux.Handle("POST /api/v1/audio/voice-clone", ownerAuth(http.HandlerFunc(s.handleVoiceClone)))
|
|
|
|
// Image upload (B1): a multipart POST that stores a raster image under the
|
|
// resolved data dir and returns a same-origin URL usable directly as
|
|
// chat.image. Upload is owner/admin + audited; the static route is a
|
|
// capability URL (unguessable name) because chat.image / <img> cannot carry a
|
|
// bearer token. Both ride the one gateway port (single-binary red line).
|
|
mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload)))
|
|
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
|
|
}
|
|
|
|
// registerHistoryRoutes mounts the persistent chat-history surface (design/16)
|
|
// plus the realtime endpoint. Session/model reads and writes are the owner's
|
|
// private history, so owner/admin only (chat uses the same roles); the message
|
|
// content (and thinking) is owner/admin-only per design/16 §6, while the session
|
|
// metadata in GET /sessions above stays readable by any authenticated role.
|
|
// Creating and deleting a sub session mutate persistent state.
|
|
func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.Handler) http.Handler) {
|
|
mux.Handle("GET /api/v1/sessions/{id}/messages", writeAuth(http.HandlerFunc(s.handleListMessages)))
|
|
mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession)))
|
|
mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession)))
|
|
mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession)))
|
|
// Read cursor (D4 / Android collaboration B3): advancing it clears the session's
|
|
// unread badge on every end of the account, so it is an owner/admin history write
|
|
// like the other session mutations. It broadcasts a `session_read` frame.
|
|
mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead)))
|
|
// Single-message edit/delete (Android collaboration batch ③a, D3). A message is
|
|
// addressed by its own opaque id; both are owner/admin-gated history writes.
|
|
mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage)))
|
|
mux.Handle("DELETE /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handleDeleteMessage)))
|
|
|
|
// Realtime.
|
|
mux.HandleFunc("/ws/chat", s.handleWS)
|
|
}
|
|
|
|
// registerAdminRoutes mounts the admin API. Three read tiers plus two write
|
|
// tiers, mirroring the role hierarchy (RBAC red line):
|
|
//
|
|
// - viewerAuth (owner/admin/viewer): the read-only monitoring surface — module
|
|
// inventories, runtime metrics/traces, the backend log tail and the
|
|
// notification feed. Every payload here is a non-sensitive projection with no
|
|
// credential, no other account's data and no message body, which is what
|
|
// makes the viewer role a usable dashboard/logs/monitoring account.
|
|
// - writeAuth reads (owner/admin): everything a viewer must not see — memories,
|
|
// the LLM provider stack, accounts/API keys, the backthink monitor, the audit
|
|
// trail, channel message logs/transcripts, full session export.
|
|
// - writeAuth writes (owner/admin): the audited knowledge search.
|
|
// - ownerAuth (owner): the audited policy write, every credential-acquiring
|
|
// channel endpoint (login-qr/login-status) and every other mutation.
|
|
//
|
|
// Each write handler persists to the real backing store or answers honestly with
|
|
// a clear "M10 / not-wired" error rather than fabricating a success.
|
|
func (s *Server) registerAdminRoutes(
|
|
mux *http.ServeMux,
|
|
ownerAuth func(http.Handler) http.Handler,
|
|
writeAuth func(http.Handler) http.Handler,
|
|
viewerAuth func(http.Handler) http.Handler,
|
|
) {
|
|
// Admin reads open to viewer: the read-only monitoring/inventory surface.
|
|
mux.Handle("GET /api/v1/admin/personas", viewerAuth(http.HandlerFunc(s.adminListPersonas)))
|
|
mux.Handle("GET /api/v1/admin/sessions", viewerAuth(http.HandlerFunc(s.adminListSessions)))
|
|
mux.Handle("GET /api/v1/admin/plugins", viewerAuth(http.HandlerFunc(s.adminListPlugins)))
|
|
mux.Handle("GET /api/v1/admin/plugins/installed", viewerAuth(http.HandlerFunc(s.adminListInstalledPlugins)))
|
|
mux.Handle("GET /api/v1/admin/channels", viewerAuth(http.HandlerFunc(s.adminListChannels)))
|
|
mux.Handle("GET /api/v1/admin/iot", viewerAuth(http.HandlerFunc(s.adminListIoT)))
|
|
mux.Handle("GET /api/v1/admin/automations", viewerAuth(http.HandlerFunc(s.adminListAutomations)))
|
|
// Knowledge-base document list: names/descriptions plus chunk/embedding
|
|
// status. The `?query=` search rides along here and stays read-only.
|
|
mux.Handle("GET /api/v1/admin/rag", viewerAuth(http.HandlerFunc(s.adminListRag)))
|
|
mux.Handle("GET /api/v1/admin/observability/metrics", viewerAuth(http.HandlerFunc(s.adminObservabilityMetrics)))
|
|
mux.Handle("GET /api/v1/admin/observability/trace", viewerAuth(http.HandlerFunc(s.adminObservabilityTrace)))
|
|
// Backend log tail: the logger's own bounded ring buffer, injected by the
|
|
// composition root (501 until then). Newest first, `?level=`/`?limit=` filtered.
|
|
mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs)))
|
|
// Unified notification feed (design/10 §3 #16): a read-only projection of the
|
|
// audit trail + backthink pushes, so the panel's notification page needs no
|
|
// new persisted model.
|
|
mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications)))
|
|
|
|
// Admin reads reserved for owner/admin: private content, other accounts'
|
|
// data and the audit trail. Not open to viewer.
|
|
mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))
|
|
mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages)))
|
|
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
|
|
mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm)))
|
|
mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers)))
|
|
mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink)))
|
|
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
|
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
|
|
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
|
mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy)))
|
|
|
|
// Admin writes: owner-only, audited.
|
|
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
|
|
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
|
|
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
|
|
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
|
|
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
|
|
mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl)))
|
|
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
|
|
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
|
|
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
|
|
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
|
|
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
|
|
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
|
|
mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm)))
|
|
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
|
|
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
|
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
|
mux.Handle("GET /api/v1/admin/channels/{platform}/login-qr", ownerAuth(http.HandlerFunc(s.adminWeixinLoginQR)))
|
|
mux.Handle("GET /api/v1/admin/channels/{platform}/login-status", ownerAuth(http.HandlerFunc(s.adminWeixinLoginStatus)))
|
|
mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView)))
|
|
mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends)))
|
|
mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle)))
|
|
mux.Handle("POST /api/v1/admin/channels/{platform}/message/test", ownerAuth(http.HandlerFunc(s.adminChannelMessageTest)))
|
|
mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig)))
|
|
mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount)))
|
|
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
|
|
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
|
|
mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory)))
|
|
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
|
|
mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag)))
|
|
mux.Handle("POST /api/v1/admin/rag/delete", ownerAuth(http.HandlerFunc(s.adminDeleteRag)))
|
|
mux.Handle("POST /api/v1/admin/system/revoke-keys", ownerAuth(http.HandlerFunc(s.adminRevokeAllKeys)))
|
|
mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT)))
|
|
mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup)))
|
|
mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore)))
|
|
}
|
|
|
|
// mountWebhooks mounts externally-supplied inbound callbacks (e.g. the webhook
|
|
// channel) without gateway auth; each handler enforces its own
|
|
// signature/authorization.
|
|
func (s *Server) mountWebhooks(mux *http.ServeMux) {
|
|
for path, hdl := range s.webhooks {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
mux.Handle(path, hdl)
|
|
}
|
|
}
|
|
|
|
// withMiddleware wraps the routed handler in the outer layers, outermost first:
|
|
// the upload capability-URL guard, the SPA fallback (when a frontend is
|
|
// configured), panic recovery, CORS and request logging.
|
|
func (s *Server) withMiddleware(mux http.Handler) http.Handler {
|
|
var h http.Handler = mux
|
|
h = guardUploadPaths(h)
|
|
if s.frontend != nil {
|
|
h = s.spa(h)
|
|
}
|
|
h = s.recover(h)
|
|
h = s.cors(h)
|
|
h = s.logging(h)
|
|
return h
|
|
}
|