From 4b87c74f41def0d9838010b63c86d752a3ffeaa0 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 2 Apr 2026 21:31:31 +0000 Subject: [PATCH] Make impersonation ending work, fix frontend events --- api/event.go | 7 +- api/handler.go | 22 ++++++ api/routes.go | 1 + auth/auth.go | 13 ++++ platform/event/event.go | 35 +++++++--- platform/impersonation.go | 53 +++++++++++++++ resource/impersonation.go | 56 ++++++++++++++++ ts/SSEManager.ts | 85 +++++++++--------------- ts/components/layout/Sidebar.vue | 11 ++- ts/components/sudo/UserImpersonation.vue | 8 +-- ts/main.ts | 8 +-- ts/store/communication.ts | 6 +- ts/store/review-task.ts | 6 +- ts/store/session.ts | 18 ++--- ts/store/signal.ts | 6 +- ts/store/upload.ts | 6 +- ts/store/user.ts | 6 +- ts/view/Communication.vue | 14 +--- 18 files changed, 255 insertions(+), 106 deletions(-) create mode 100644 platform/impersonation.go create mode 100644 resource/impersonation.go diff --git a/api/event.go b/api/event.go index 6fcdaf53..9bf24924 100644 --- a/api/event.go +++ b/api/event.go @@ -17,7 +17,7 @@ type ConnectionSSE struct { chanEvent chan platform.Event id uuid.UUID organizationID int32 - userID int + userID int32 } type Message struct { @@ -50,6 +50,9 @@ func SetEventChannel(chan_envelopes <-chan platform.Envelope) { if conn.organizationID == envelope.OrganizationID { log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Msg("pushed event to client") conn.chanEvent <- envelope.Event + } else if conn.userID == envelope.UserID { + log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-user", envelope.UserID).Msg("pushed event to user") + conn.chanEvent <- envelope.Event } else { log.Debug().Int("type", int(envelope.Event.Type)).Int32("env-org", envelope.OrganizationID).Int32("conn-org", conn.organizationID).Msg("skipped event, bad org") } @@ -87,7 +90,7 @@ func streamEvents(w http.ResponseWriter, r *http.Request, u platform.User) { chanEvent: make(chan platform.Event), id: uid, organizationID: u.Organization.ID, - userID: u.ID, + userID: int32(u.ID), } connectionsSSE[&connection] = true log.Debug().Int32("org", u.Organization.ID).Int("user", u.ID).Str("id", uid.String()).Msg("connected SSE client") diff --git a/api/handler.go b/api/handler.go index acf49e0b..85882efb 100644 --- a/api/handler.go +++ b/api/handler.go @@ -20,6 +20,28 @@ import ( var decoder = schema.NewDecoder() +type handlerFunctionDelete func(context.Context, *http.Request, platform.User) *nhttp.ErrorWithStatus + +func authenticatedHandlerDelete(f handlerFunctionDelete) http.Handler { + return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) { + ctx := r.Context() + e := f(ctx, r, u) + if e != nil { + log.Warn().Int("status", e.Status).Err(e).Str("user message", e.Message).Msg("Responding with an error from api") + body, err := json.Marshal(ErrorAPI{Message: e.Error()}) + if err != nil { + log.Error().Err(err).Msg("failed to marshal error") + http.Error(w, "{\"message\": \"boom. I can't even tell you what went wrong\"}", http.StatusInternalServerError) + return + } + http.Error(w, string(body), e.Status) + return + } + http.Error(w, "", http.StatusNoContent) + return + }) +} + type handlerFunctionGetImage func(context.Context, *http.Request, platform.User) (file.Collection, uuid.UUID, *nhttp.ErrorWithStatus) func authenticatedHandlerGetImage(f handlerFunctionGetImage) http.Handler { diff --git a/api/routes.go b/api/routes.go index a946d0e2..d56baf1b 100644 --- a/api/routes.go +++ b/api/routes.go @@ -29,6 +29,7 @@ func AddRoutes(r *mux.Router) { r.Handle("/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentPost)).Methods("POST") impersonation := resource.Impersonation(router) r.Handle("/impersonation", authenticatedHandlerJSONPost(impersonation.Create)).Methods("POST") + r.Handle("/impersonation", authenticatedHandlerDelete(impersonation.Delete)).Methods("DELETE") lead := resource.Lead(r) r.Handle("/leads", authenticatedHandlerJSON(lead.List)).Methods("GET") r.Handle("/leads", authenticatedHandlerJSONPost(lead.Create)).Methods("POST") diff --git a/auth/auth.go b/auth/auth.go index fa34d815..8be51083 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -36,6 +36,9 @@ func AddUserSession(ctx context.Context, user *platform.User) { sessionManager.Put(ctx, "username", user.Username) log.Debug().Str("id", id_str).Str("username", user.Username).Msg("added user session") } +func ImpersonateEnd(ctx context.Context) { + sessionManager.Put(ctx, "impersonated_user_id", "") +} func ImpersonateUser(ctx context.Context, target_user_id int) { target_user_id_str := strconv.Itoa(int(target_user_id)) sessionManager.Put(ctx, "impersonated_user_id", target_user_id_str) @@ -53,7 +56,17 @@ func ImpersonatedUser(ctx context.Context) *int32 { result := int32(i) return &result } +func ImpersonatorID(ctx context.Context) *int32 { + user_id_str := sessionManager.GetString(ctx, "user_id") + user_id, err := strconv.Atoi(user_id_str) + if err != nil { + log.Error().Err(err).Str("user_id", user_id_str).Msg("failed to parse user_id") + return nil + } + result := int32(user_id) + return &result +} func GetAuthenticatedUser(r *http.Request) (*platform.User, error) { ctx := r.Context() user_id_str := sessionManager.GetString(ctx, "user_id") diff --git a/platform/event/event.go b/platform/event/event.go index c6f2c778..6ec94663 100644 --- a/platform/event/event.go +++ b/platform/event/event.go @@ -26,8 +26,9 @@ func (e Event) MarshalJSON() ([]byte, error) { } type Envelope struct { - OrganizationID int32 Event Event + OrganizationID int32 + UserID int32 } func SetEventChannel(chan_events chan<- Envelope) { @@ -90,6 +91,7 @@ const ( TypeRMONuisance TypeRMOReport TypeRMOWater + TypeSession TypeSignal ) @@ -115,6 +117,17 @@ func Updated(t ResourceType, organization_id int32, uri_id string) { OrganizationID: organization_id, }) } +func UpdatedUser(t ResourceType, user_id int32, uri_id string) { + go Send(Envelope{ + Event: Event{ + Resource: resourceString(t), + Time: time.Now(), + Type: EventTypeUpdated, + URI: makeURI(t, uri_id), + }, + UserID: user_id, + }) +} func Send(env Envelope) { chanEvents <- env } @@ -127,13 +140,15 @@ func resourceString(t ResourceType) string { case TypeNoteImage: return "sync:note:image" case TypeReviewTask: - return "sync:review_task" + return "sync:review-task" case TypeRMONuisance: return "rmo:nuisance" case TypeRMOReport: return "rmo:report" case TypeRMOWater: return "rmo:water" + case TypeSession: + return "sync:session" case TypeSignal: return "sync:signal" default: @@ -143,19 +158,21 @@ func resourceString(t ResourceType) string { func makeURI(t ResourceType, id string) string { switch t { case TypeFileCSV: - return config.MakeURLNidus("/upload/%s", id) + return config.MakeURLNidus("/api/upload/%s", id) case TypeNoteAudio: - return config.MakeURLNidus("/note/%s", id) + return config.MakeURLNidus("/api/note/%s", id) case TypeNoteImage: - return config.MakeURLNidus("/note/%s", id) + return config.MakeURLNidus("/api/note/%s", id) case TypeReviewTask: - return config.MakeURLNidus("/review/%s", id) + return config.MakeURLNidus("/api/review/%s", id) case TypeRMONuisance: - return config.MakeURLReport("/report/%s", id) + return config.MakeURLReport("/api/report/%s", id) case TypeRMOWater: - return config.MakeURLReport("/report/%s", id) + return config.MakeURLReport("/api/report/%s", id) + case TypeSession: + return config.MakeURLReport("/api/session") case TypeSignal: - return config.MakeURLReport("/signal/%s", id) + return config.MakeURLReport("/api/signal/%s", id) default: return config.MakeURLReport("/unknown") } diff --git a/platform/impersonation.go b/platform/impersonation.go new file mode 100644 index 00000000..51bea066 --- /dev/null +++ b/platform/impersonation.go @@ -0,0 +1,53 @@ +package platform + +import ( + "context" + "fmt" + "time" + + "github.com/Gleipnir-Technology/nidus-sync/db" + "github.com/Gleipnir-Technology/nidus-sync/db/models" + "github.com/Gleipnir-Technology/nidus-sync/platform/event" + "github.com/aarondl/opt/omit" + "github.com/aarondl/opt/omitnull" + "github.com/rs/zerolog/log" +) + +func ImpersonationCreate(ctx context.Context, user User, target int) (*models.LogImpersonation, error) { + if !user.HasRoot() { + return nil, fmt.Errorf("user %d is not root, and therefore can't impersonate user %d", user.ID, target) + } + setter := models.LogImpersonationSetter{ + BeginAt: omit.From(time.Now()), + EndAt: omitnull.FromPtr[time.Time](nil), + //ID: , + ImpersonatorID: omit.From(int32(user.ID)), + TargetID: omit.From(int32(target)), + } + log, err := models.LogImpersonations.Insert(&setter).One(ctx, db.PGInstance.BobDB) + if err != nil { + return nil, fmt.Errorf("insert log: %w", err) + } + event.UpdatedUser(event.TypeSession, user.model.ID, "") + event.UpdatedUser(event.TypeSession, int32(target), "") + return log, nil +} +func ImpersonationEnd(ctx context.Context, user User, impersonator_id int32) error { + l, err := models.LogImpersonations.Query( + models.SelectWhere.LogImpersonations.EndAt.IsNull(), + models.SelectWhere.LogImpersonations.ImpersonatorID.EQ(impersonator_id), + ).One(ctx, db.PGInstance.BobDB) + if err != nil { + return fmt.Errorf("query impersonations: %w", err) + } + err = l.Update(ctx, db.PGInstance.BobDB, &models.LogImpersonationSetter{ + EndAt: omitnull.From(time.Now()), + }) + if err != nil { + return fmt.Errorf("update impersonation log: %w", err) + } + log.Info().Int32("impersonator", l.ImpersonatorID).Int32("target", l.TargetID).Msg("Stopped impersonating") + event.UpdatedUser(event.TypeSession, user.model.ID, "") + event.UpdatedUser(event.TypeSession, impersonator_id, "") + return nil +} diff --git a/resource/impersonation.go b/resource/impersonation.go new file mode 100644 index 00000000..d391d2c4 --- /dev/null +++ b/resource/impersonation.go @@ -0,0 +1,56 @@ +package resource + +import ( + "context" + "net/http" + + "github.com/Gleipnir-Technology/nidus-sync/auth" + nhttp "github.com/Gleipnir-Technology/nidus-sync/http" + "github.com/Gleipnir-Technology/nidus-sync/platform" + "github.com/aarondl/opt/omit" + "github.com/rs/zerolog/log" +) + +func Impersonation(r *router) *impersonationR { + return &impersonationR{ + router: r, + } +} + +type impersonationR struct { + router *router +} +type impersonation struct { + UserID omit.Val[int] `json:"id"` +} + +func (res *impersonationR) Create(ctx context.Context, r *http.Request, u platform.User, i impersonation) (*impersonation, *nhttp.ErrorWithStatus) { + if i.UserID.IsUnset() { + return nil, nhttp.NewBadRequest("you must provide an 'id'") + } + target_id := i.UserID.MustGet() + l, err := platform.ImpersonationCreate(ctx, u, target_id) + if err != nil { + return nil, nhttp.NewError("create impersonation: %w", err) + } + auth.ImpersonateUser(ctx, target_id) + log.Info().Int("user.id", u.ID).Str("username", u.Username).Int("target.id", target_id).Int32("log.id", l.ID).Msg("Impersonation begins") + return &impersonation{ + UserID: i.UserID, + }, nil +} +func (res *impersonationR) Delete(ctx context.Context, r *http.Request, u platform.User) *nhttp.ErrorWithStatus { + if auth.ImpersonatedUser == nil { + return nhttp.NewBadRequest("not impersonating") + } + real_user_id := auth.ImpersonatorID(ctx) + if real_user_id == nil { + return nhttp.NewError("No impersonator ID") + } + err := platform.ImpersonationEnd(ctx, u, *real_user_id) + if err != nil { + return nhttp.NewError("end impersonation: %w", err) + } + auth.ImpersonateEnd(ctx) + return nil +} diff --git a/ts/SSEManager.ts b/ts/SSEManager.ts index 58d9d6e9..6011496c 100644 --- a/ts/SSEManager.ts +++ b/ts/SSEManager.ts @@ -1,8 +1,9 @@ // Define types for the SSE data structure -interface SSEMessage { +export interface SSEMessage { + resource: string; + time: string; type: string; - count?: number; - [key: string]: any; // Allow additional properties + uri: string; } type SSEHandler = (data: SSEMessage) => void; @@ -10,48 +11,25 @@ type SSEHandler = (data: SSEMessage) => void; interface SSEManagerType { connect: (url: string) => Promise; disconnect: () => void; - subscribe: (eventType: string, handler: SSEHandler) => void; - unsubscribe: (eventType: string, handler: SSEHandler) => void; + subscribe: (handler: SSEHandler) => string; + unsubscribe: (uuid: string) => void; ready: (callback: (eventSource: EventSource) => void) => void; } +/* declare global { interface Window { SSEManager: SSEManagerType; } } +*/ export const SSEManager: SSEManagerType = (function (): SSEManagerType { let eventSource: EventSource | null = null; - let subscribers: Map = new Map(); + let subscribers: Map = new Map(); let isConnected: boolean = false; let connectionPromise: Promise | null = null; - function subscribe(eventType: string, handler: SSEHandler): void { - if (!subscribers.has(eventType)) { - subscribers.set(eventType, []); - } - subscribers.get(eventType)!.push(handler); - - // If already connected, attach the listener immediately - if (isConnected && eventSource) { - eventSource.addEventListener(eventType, handler as EventListener); - } - } - - function unsubscribe(eventType: string, handler: SSEHandler): void { - if (subscribers.has(eventType)) { - const handlers = subscribers.get(eventType)!; - const index = handlers.indexOf(handler); - if (index > -1) { - handlers.splice(index, 1); - } - } - if (eventSource) { - eventSource.removeEventListener(eventType, handler as EventListener); - } - } - function connect(url: string): Promise { if (connectionPromise) { return connectionPromise; @@ -63,19 +41,9 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { eventSource.onopen = function (): void { isConnected = true; - // Attach all pre-registered handlers - subscribers.forEach((handlers: SSEHandler[], eventType: string) => { - handlers.forEach((handler: SSEHandler) => { - eventSource!.addEventListener( - "message", - (message: MessageEvent) => { - const data: SSEMessage = JSON.parse(message.data); - if (eventType === "*" || eventType === data.type) { - handler(data); - } - }, - ); - }); + eventSource!.addEventListener("message", (message: MessageEvent) => { + const data: SSEMessage = JSON.parse(message.data); + handleMessage(data); }); console.log("SSE connected"); @@ -115,6 +83,15 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { } } + function handleMessage(msg: SSEMessage) { + if (msg.type == "heartbeat") { + return; + } + subscribers.forEach((handler: SSEHandler, _: string) => { + handler(msg); + }); + } + function ready(callback: (eventSource: EventSource) => void): void { if (connectionPromise) { connectionPromise.then(callback); @@ -129,6 +106,18 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { } } + function subscribe(handler: SSEHandler): string { + const uuid = crypto.randomUUID(); + subscribers.set(uuid.toString(), handler); + return uuid; + } + + function unsubscribe(uuid: string): void { + if (subscribers.has(uuid)) { + subscribers.delete(uuid); + } + } + return { connect, disconnect, @@ -137,11 +126,3 @@ export const SSEManager: SSEManagerType = (function (): SSEManagerType { ready, }; })(); - -function updateNotificationBadge(data: SSEMessage): void { - const badge = document.querySelector(".notification-badge"); - if (badge) { - badge.textContent = String(data.count || 0); - badge.style.display = (data.count || 0) > 0 ? "block" : "none"; - } -} diff --git a/ts/components/layout/Sidebar.vue b/ts/components/layout/Sidebar.vue index 78f6ce6f..1843388c 100644 --- a/ts/components/layout/Sidebar.vue +++ b/ts/components/layout/Sidebar.vue @@ -208,8 +208,9 @@ import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from "vue"; import { Tooltip, Popover } from "bootstrap"; import NavigationLink from "@/components/common/NavigationLink.vue"; -import { SSEManager } from "@/SSEManager"; +import { SSEManager, type SSEMessage } from "@/SSEManager"; import { useSessionStore } from "@/store/session"; +import type { Session } from "@/types"; // Reactive state const isCollapsed = ref(false); @@ -279,6 +280,14 @@ const setTooltipsForSidebar = () => { // Lifecycle hooks onMounted(async () => { + const sub = SSEManager.subscribe((msg: SSEMessage) => { + if (msg.resource != "sync:session") { + return; + } + session.fetchSession().then((s: Session) => { + isImpersonating.value = !!s.impersonating; + }); + }); restoreLocalStorage(); await nextTick(); diff --git a/ts/components/sudo/UserImpersonation.vue b/ts/components/sudo/UserImpersonation.vue index b0e76b29..36c74a60 100644 --- a/ts/components/sudo/UserImpersonation.vue +++ b/ts/components/sudo/UserImpersonation.vue @@ -8,9 +8,9 @@ Impersonate User
-