mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 17:40:11 +02:00
* fix(avatar): serve avatars through a signed endpoint on private buckets (MUL-5393) Avatar uploads persisted the raw storage object URL into `avatar_url`. On a deployment whose bucket is private and has no public CDN domain (S3 with Block Public Access, R2, MinIO) that URL is a guaranteed 403 in the browser: ATTACHMENT_DOWNLOAD_MODE only ever applied to the attachment download endpoint, so every user / agent / squad / workspace avatar rendered broken even though the upload itself succeeded. Resolve at read time instead of at upload time. What is persisted stays the durable object reference, so nothing with a TTL is ever written to the database and avatars saved by an older build are fixed without a backfill. What is served is `/api/avatars/<sig>/<key>`, a stable URL the server resolves per request through the deployment's existing storage download policy (presigned redirect, CloudFront-signed redirect, or proxied body). The endpoint is unauthenticated and the HMAC signature is the credential: the session cookie is SameSite=Strict, so an auth-gated URL cannot be a native <img src> from Desktop, a mobile webview, or a split-origin self-hosted web app. The signature covers the storage key and only image extensions resolve, so an avatar_url pointed at a private document cannot launder it into a publicly fetchable URL. Deployments that already work are untouched: a public CDN domain without per-request signing, and the local-disk backend whose /uploads/* route is public, both keep returning the raw URL. Fixes #6024 Co-authored-by: multica-agent <github@multica.ai> * fix(avatar): only publish avatar-class objects through the signed endpoint (MUL-5393) Review found that being able to name a storage object was treated as permission to publish it. `ownedStorageKey` proved only that a URL came from this deployment's storage, and every image-shaped key was then signed — while the avatar update endpoints accepted any raw storage URL. A caller who had seen a private image attachment's URL could submit it as their own avatar, and the unauthenticated endpoint would keep re-signing it indefinitely. A user avatar propagates to every workspace that user belongs to, so the leak crossed workspace boundaries. Add the missing authorization rule: an object is serveable as an avatar only when it is avatar-class — a standalone image upload not attached to an issue, comment, chat session, chat message, or task. The check resolves the backing attachment row from the id UploadFile embeds in the object filename, so it needs no lookup by URL and no new index. It is enforced on both sides. The write side rejects such a value with 403 before anything is stored; the read side re-checks per request, which is what makes the guarantee hold for rows written before this existed and revokes the URL if an object is later bound to a comment or chat. Scope is the `workspaces/` namespace — the only place that can hold content belonging to someone other than whoever is setting the avatar, covering both uploads and channel media ingest. Keys elsewhere (the per-user standalone namespace, or objects an operator placed in the bucket) stay usable, which keeps the documented "an explicit avatar_url is preserved" contract intact. Uploader identity is deliberately not part of the rule: duplicating an agent legitimately reuses the source agent's avatar object, which a different admin may have uploaded. Publishing someone else's unbound image would require knowing its URL, and unbound rows appear in no listing endpoint. Also clamp the 302's cache lifetime to half the signed URL's own TTL (0 -> no-store). ATTACHMENT_DOWNLOAD_URL_TTL takes any positive duration, so the fixed 60s could outlive the target it pointed at on a short-TTL deployment. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
229 lines
8.9 KiB
Go
229 lines
8.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// revokeAndRemoveMember converges all server-side state that should follow a
|
|
// member leaving a workspace: every runtime they own becomes unusable, every
|
|
// agent pinned to one of those runtimes is archived, every in-flight task on
|
|
// those runtimes is cancelled (cancelled rather than failed so the daemon's
|
|
// per-task status poller interrupts the running agent gracefully), the
|
|
// daemon_token rows for those runtimes are deleted, and finally the member row
|
|
// itself is removed.
|
|
//
|
|
// All DB writes run inside a single transaction so a partial revocation never
|
|
// leaves the workspace half-converged — e.g. a member who is "gone" but whose
|
|
// runtime row is still active. Once the transaction commits, daemon_token
|
|
// cache entries are invalidated and events are published (see
|
|
// publishRevocation) so connected clients and other workspace members observe
|
|
// the new state immediately.
|
|
//
|
|
// Note on scope: this revokes every runtime whose owner_id matches userID,
|
|
// regardless of how the daemon authenticates. Today most daemons fall back to
|
|
// PAT/JWT and `daemon_token` rows are unused in production; deleting them is
|
|
// a no-op for those daemons but takes effect once the mdt_ flow is live.
|
|
// Either way the agent-archive + task-cancel + force-offline writes are the
|
|
// actual production safety net: even if the daemon races back online with a
|
|
// still-valid PAT, it finds no agent it can run for, no queued task to claim,
|
|
// and the dispatcher (which gates on agent.archived_at IS NULL) won't hand it
|
|
// new work — and the member-row deletion in the same tx means subsequent
|
|
// requireWorkspaceMember checks will reject the daemon's PAT-authenticated
|
|
// requests with 404.
|
|
//
|
|
// archivedBy is the actor who triggered the revocation. For DeleteMember it's
|
|
// the requester (the admin doing the kick); for LeaveWorkspace it's the leaver
|
|
// themselves.
|
|
func (h *Handler) revokeAndRemoveMember(ctx context.Context, workspaceID, userID, memberID, archivedBy pgtype.UUID) (revocationResult, error) {
|
|
var empty revocationResult
|
|
|
|
tx, err := h.TxStarter.Begin(ctx)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
runtimes, err := qtx.ListAgentRuntimesByOwner(ctx, db.ListAgentRuntimesByOwnerParams{
|
|
WorkspaceID: workspaceID,
|
|
OwnerID: userID,
|
|
})
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
result := revocationResult{Runtimes: runtimes}
|
|
|
|
if len(runtimes) > 0 {
|
|
runtimeIDs := make([]pgtype.UUID, len(runtimes))
|
|
daemonIDs := make([]string, 0, len(runtimes))
|
|
for i, rt := range runtimes {
|
|
runtimeIDs[i] = rt.ID
|
|
if rt.DaemonID.Valid && rt.DaemonID.String != "" {
|
|
daemonIDs = append(daemonIDs, rt.DaemonID.String)
|
|
}
|
|
}
|
|
|
|
result.ArchivedAgents, err = qtx.ArchiveAgentsByRuntime(ctx, db.ArchiveAgentsByRuntimeParams{
|
|
ArchivedBy: archivedBy,
|
|
RuntimeIds: runtimeIDs,
|
|
})
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
// Cancel by runtime AND by archived agent. agent.runtime_id can be
|
|
// reassigned via UpdateAgent without rewriting the runtime_id on
|
|
// historical agent_task_queue rows, so an archived agent may still
|
|
// have queued/running tasks pinned to a different runtime — and
|
|
// ClaimAgentTask does not gate on agent.archived_at, so those tasks
|
|
// would otherwise stay claimable after the agent is gone.
|
|
archivedAgentIDs := make([]pgtype.UUID, len(result.ArchivedAgents))
|
|
for i, a := range result.ArchivedAgents {
|
|
archivedAgentIDs[i] = a.ID
|
|
}
|
|
result.CancelledTasks, err = qtx.CancelAgentTasksByRuntimeOrAgent(ctx, db.CancelAgentTasksByRuntimeOrAgentParams{
|
|
RuntimeIds: runtimeIDs,
|
|
AgentIds: archivedAgentIDs,
|
|
})
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
result.OfflineRuntimeIDs, err = qtx.ForceOfflineRuntimesByIDs(ctx, runtimeIDs)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
if len(daemonIDs) > 0 {
|
|
result.RevokedTokenHashes, err = qtx.DeleteDaemonTokensByWorkspaceAndDaemons(ctx, db.DeleteDaemonTokensByWorkspaceAndDaemonsParams{
|
|
WorkspaceID: workspaceID,
|
|
DaemonIds: daemonIDs,
|
|
})
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// channel_user_binding used to carry a member FK with ON DELETE CASCADE, so
|
|
// a removed member's IM bindings vanished automatically. MUL-3515 §4 dropped
|
|
// every channel_* foreign key, moving that integrity rule to the application
|
|
// layer: prune the bindings here, in the same tx as the member-row delete.
|
|
// The inbound path also re-checks membership (see ChannelStore.IsWorkspaceMember),
|
|
// but pruning stops a stale binding from lingering across a remove/re-add.
|
|
if err := qtx.DeleteChannelUserBindingsByWorkspaceMember(ctx, db.DeleteChannelUserBindingsByWorkspaceMemberParams{
|
|
WorkspaceID: workspaceID,
|
|
MulticaUserID: userID,
|
|
}); err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
// agent_invocation_target carries member-target grants with NO database FK
|
|
// (MUL-3963 keeps the new table FK-free, matching the MUL-3515 channel
|
|
// generalization). Prune this leaving member's grants in the same tx as the
|
|
// member-row delete so a re-invited user does not silently reclaim old
|
|
// invocation permission on agents that had allow-listed them. SCOPED to
|
|
// this workspace: the same user may belong to other workspaces, and
|
|
// removing them here must not touch their grants on agents elsewhere.
|
|
if err := qtx.DeleteAgentInvocationTargetsByMember(ctx, db.DeleteAgentInvocationTargetsByMemberParams{
|
|
WorkspaceID: workspaceID,
|
|
TargetID: userID,
|
|
}); err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
// Member row deletion lives inside the same tx so a successful revoke is
|
|
// never followed by a failed member-delete (which would leave the user
|
|
// still a member with a dead runtime), and a failed revoke never leaves
|
|
// the user out of the workspace with a still-online runtime.
|
|
if err := qtx.DeleteMember(ctx, memberID); err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return empty, err
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// revocationResult captures everything revokeMemberRuntimes touched so the
|
|
// caller can fan out events and analytics after the transaction commits.
|
|
// Publishing inside the transaction would let subscribers observe a state the
|
|
// tx might still roll back (see TaskService.BroadcastCancelledTasks docstring).
|
|
type revocationResult struct {
|
|
Runtimes []db.AgentRuntime
|
|
ArchivedAgents []db.Agent
|
|
CancelledTasks []db.AgentTaskQueue
|
|
OfflineRuntimeIDs []db.ForceOfflineRuntimesByIDsRow
|
|
RevokedTokenHashes []string
|
|
}
|
|
|
|
func (r revocationResult) isEmpty() bool {
|
|
return len(r.Runtimes) == 0
|
|
}
|
|
|
|
// publishRevocation runs all post-commit side effects: invalidate daemon token
|
|
// cache, broadcast task:cancelled with per-agent reconciliation, broadcast
|
|
// agent:archived, and signal a runtime-list refresh. Safe to call on an empty
|
|
// result — it returns immediately.
|
|
func (h *Handler) publishRevocation(ctx context.Context, result revocationResult, workspaceIDStr, actorType, actorIDStr string) {
|
|
if result.isEmpty() {
|
|
return
|
|
}
|
|
|
|
for _, hash := range result.RevokedTokenHashes {
|
|
h.DaemonTokenCache.Invalidate(ctx, hash)
|
|
}
|
|
|
|
// Per-task cancellation: TaskService handles status reconciliation and
|
|
// per-task event broadcast. Run this before the agent:archived burst so
|
|
// subscribers see "task cancelled" before the parent agent disappears
|
|
// from active lists, matching the order ArchiveAgent uses.
|
|
if h.TaskService != nil && len(result.CancelledTasks) > 0 {
|
|
h.TaskService.BroadcastCancelledTasks(ctx, result.CancelledTasks)
|
|
}
|
|
|
|
for _, agent := range result.ArchivedAgents {
|
|
h.publish(protocol.EventAgentArchived, workspaceIDStr, actorType, actorIDStr, map[string]any{
|
|
"agent": h.agentToResponse(agent),
|
|
})
|
|
}
|
|
|
|
// Tell connected clients to refresh the runtime list. We piggyback on
|
|
// EventDaemonRegister with a "revoke" action — same channel the runtime
|
|
// delete handler uses — so the frontend invalidates its cached list
|
|
// without us having to introduce a new event type the desktop app would
|
|
// need a build to learn about.
|
|
if len(result.OfflineRuntimeIDs) > 0 {
|
|
h.publish(protocol.EventDaemonRegister, workspaceIDStr, actorType, actorIDStr, map[string]any{
|
|
"action": "revoke",
|
|
})
|
|
}
|
|
}
|
|
|
|
// logRevocation emits a structured info line summarising the revocation. Kept
|
|
// separate from publish so the log is identical whether or not the bus is wired.
|
|
func logRevocation(result revocationResult, workspaceID, userID string, attrs ...any) {
|
|
if result.isEmpty() {
|
|
return
|
|
}
|
|
base := []any{
|
|
"workspace_id", workspaceID,
|
|
"user_id", userID,
|
|
"runtimes_revoked", len(result.Runtimes),
|
|
"agents_archived", len(result.ArchivedAgents),
|
|
"tasks_cancelled", len(result.CancelledTasks),
|
|
"runtimes_taken_offline", len(result.OfflineRuntimeIDs),
|
|
"daemon_tokens_revoked", len(result.RevokedTokenHashes),
|
|
}
|
|
slog.Info("member runtimes revoked", append(base, attrs...)...)
|
|
}
|