diff --git a/server/cmd/main.go b/server/cmd/main.go index 3199a39..cc63790 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -39,10 +39,16 @@ func main() { log.Printf("warning: failed to ensure workflow indexes: %v", err) } - if created, updated, err := services.SeedDefaultSteps(); err != nil { - log.Printf("warning: failed to seed default steps: %v", err) + if orgIDs, err := services.ListOrgIDs(); err != nil { + log.Printf("warning: failed to list orgs for default step seeding: %v", err) } else { - log.Printf("default steps seeded: %d created, %d updated", created, updated) + for _, orgID := range orgIDs { + if created, updated, err := services.SeedDefaultSteps(orgID); err != nil { + log.Printf("warning: failed to seed default steps for org %s: %v", orgID, err) + } else { + log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated) + } + } } services.StartLogSweeper() diff --git a/server/internal/api/channels.go b/server/internal/api/channels.go index f707904..790edc5 100644 --- a/server/internal/api/channels.go +++ b/server/internal/api/channels.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/models" "github.com/mrhid6/vantage/server/internal/services" "go.mongodb.org/mongo-driver/v2/bson" @@ -18,7 +19,7 @@ func registerChannelRoutes(g *gin.RouterGroup) { } func listChannels(c *gin.Context) { - channels, err := services.ListChannels() + channels, err := services.ListChannels(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -36,7 +37,7 @@ func createChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } - created, err := services.CreateChannel(&ch) + created, err := services.CreateChannel(auth.OrgID(c), &ch) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -72,7 +73,7 @@ func updateChannel(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - if err := services.UpdateChannel(c.Param("id"), upd); err != nil { + if err := services.UpdateChannel(auth.OrgID(c), c.Param("id"), upd); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -80,7 +81,7 @@ func updateChannel(c *gin.Context) { } func deleteChannel(c *gin.Context) { - if err := services.DeleteChannel(c.Param("id")); err != nil { + if err := services.DeleteChannel(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -88,7 +89,7 @@ func deleteChannel(c *gin.Context) { } func testChannel(c *gin.Context) { - if err := services.TestChannel(c.Param("id")); err != nil { + if err := services.TestChannel(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } diff --git a/server/internal/api/console.go b/server/internal/api/console.go index bcc390d..b108a02 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/services" "github.com/wwt/guac" ) @@ -29,7 +30,7 @@ func consoleConnect(c *gin.Context) { return } - srv, err := services.GetServer(body.ServerID) + srv, err := services.GetServer(auth.OrgID(c), body.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -60,7 +61,7 @@ func consoleConnect(c *gin.Context) { } } - services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "", + services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "", "console session opened ("+body.Protocol+")") c.JSON(http.StatusOK, gin.H{ @@ -107,7 +108,7 @@ func consoleTunnel(c *gin.Context) { return } - srv, err := services.GetServer(sess.ServerID) + srv, err := services.GetServer(auth.OrgID(c), sess.ServerID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -116,7 +117,7 @@ func consoleTunnel(c *gin.Context) { // Decrypt private key + passphrase in-memory only (ssh). var privKey, passphrase string if sess.Protocol == "ssh" && sess.KeyID != "" { - privKey, err = services.GetPrivateKey(sess.KeyID) + privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"}) return diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 69b58aa..953a979 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -85,11 +85,22 @@ func RegisterRoutes(r *gin.Engine) { registerWorkflowRoutes(apiGroup) registerMonitorRoutes(apiGroup) registerChannelRoutes(apiGroup) + + org := apiGroup.Group("/org") + org.Use(auth.RequireRole("owner", "admin")) + { + org.GET("/users", listOrgUsers) + org.POST("/users", createOrgUser) + org.PUT("/users/:id/role", updateOrgUserRole) + org.DELETE("/users/:id", deleteOrgUser) + org.GET("/oidc", getOrgOIDC) + org.PUT("/oidc", putOrgOIDC) + } } } func listServers(c *gin.Context) { - servers, err := services.ListServers() + servers, err := services.ListServers(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -98,7 +109,7 @@ func listServers(c *gin.Context) { } func createServer(c *gin.Context) { - s, token, err := services.CreateServer() + s, token, err := services.CreateServer(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -111,12 +122,12 @@ func createServer(c *gin.Context) { } func newServer(c *gin.Context) { - s, token, err := services.CreateServer() + s, token, err := services.CreateServer(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") + services.LogEvent(auth.OrgID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued") giteaHost := os.Getenv("GITEA_HOST") if giteaHost == "" { @@ -147,13 +158,13 @@ func newServer(c *gin.Context) { func getServer(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return } - assignments, _ := services.GetAssignmentsWithKeysForServer(id) + assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id) // Build response matching ServerWithKeys shape expected by frontend type serverResponse struct { @@ -168,8 +179,8 @@ func getServer(c *gin.Context) { func deleteServer(c *gin.Context) { id := c.Param("id") - s, _ := services.GetServer(id) - if err := services.DeleteServer(id); err != nil { + s, _ := services.GetServer(auth.OrgID(c), id) + if err := services.DeleteServer(auth.OrgID(c), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -177,7 +188,7 @@ func deleteServer(c *gin.Context) { if s != nil { hostname = s.Hostname } - services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) + services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -196,7 +207,7 @@ func generateKey(c *gin.Context) { body.Label = "generated" } - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -214,7 +225,7 @@ func generateKey(c *gin.Context) { return } - services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) + services.LogEvent(auth.OrgID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType)) c.JSON(http.StatusAccepted, gin.H{ "message": "key generation command sent to agent", "command_id": cmdID, @@ -223,7 +234,7 @@ func generateKey(c *gin.Context) { } func listKeys(c *gin.Context) { - keys, err := services.ListKeys() + keys, err := services.ListKeys(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -243,18 +254,18 @@ func createKey(c *gin.Context) { return } - key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) + key, err := services.CreateKey(auth.OrgID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label)) + services.LogEvent(auth.OrgID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label)) c.JSON(http.StatusCreated, key) } func getPrivateKey(c *gin.Context) { id := c.Param("id") - plaintext, err := services.GetPrivateKey(id) + plaintext, err := services.GetPrivateKey(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -264,13 +275,13 @@ func getPrivateKey(c *gin.Context) { func getKey(c *gin.Context) { id := c.Param("id") - key, err := services.GetKey(id) + key, err := services.GetKey(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "key not found"}) return } - assignments, _ := services.GetAssignmentsWithServers(id) + assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id) type keyResponse struct { *models.Key @@ -284,8 +295,8 @@ func getKey(c *gin.Context) { func deleteKey(c *gin.Context) { id := c.Param("id") - k, _ := services.GetKey(id) - if err := services.DeleteKey(id); err != nil { + k, _ := services.GetKey(auth.OrgID(c), id) + if err := services.DeleteKey(auth.OrgID(c), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -293,7 +304,7 @@ func deleteKey(c *gin.Context) { if k != nil { label = k.Label } - services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) + services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -307,12 +318,12 @@ func assignKey(c *gin.Context) { return } - a, err := services.AssignKey(keyID, body.ServerID) + a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID)) + services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID)) c.JSON(http.StatusCreated, a) } @@ -320,11 +331,11 @@ func revokeAssignment(c *gin.Context) { keyID := c.Param("id") serverID := c.Param("serverId") - if err := services.RevokeAssignment(keyID, serverID); err != nil { + if err := services.RevokeAssignment(auth.OrgID(c), keyID, serverID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) + services.LogEvent(auth.OrgID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID)) c.JSON(http.StatusOK, gin.H{"revoked": true}) } @@ -339,7 +350,7 @@ func getLatestAgentVersion(c *gin.Context) { func updateAgent(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -350,7 +361,7 @@ func updateAgent(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) + services.LogEvent(auth.OrgID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version)) c.JSON(http.StatusAccepted, gin.H{ "message": "update command sent to agent", "version": version, @@ -359,7 +370,7 @@ func updateAgent(c *gin.Context) { func applyUpdates(c *gin.Context) { id := c.Param("id") - s, err := services.GetServer(id) + s, err := services.GetServer(auth.OrgID(c), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) return @@ -369,7 +380,7 @@ func applyUpdates(c *gin.Context) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) + services.LogEvent(auth.OrgID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname)) c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"}) } @@ -436,7 +447,7 @@ func listAuditEvents(c *gin.Context) { limit = n } } - events, err := services.ListAuditEvents(limit) + events, err := services.ListAuditEvents(auth.OrgID(c), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -467,7 +478,7 @@ func saveSettings(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated") + services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated") c.JSON(http.StatusOK, gin.H{"saved": true}) } diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 2e5f614..4e50cbc 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/models" "github.com/mrhid6/vantage/server/internal/services" "go.mongodb.org/mongo-driver/v2/bson" @@ -21,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) { } func listMonitors(c *gin.Context) { - monitors, err := services.ListMonitors() + monitors, err := services.ListMonitors(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -39,7 +40,7 @@ func createMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } - created, err := services.CreateMonitor(&m) + created, err := services.CreateMonitor(auth.OrgID(c), &m) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -48,7 +49,7 @@ func createMonitor(c *gin.Context) { } func getMonitor(c *gin.Context) { - m, err := services.GetMonitor(c.Param("id")) + m, err := services.GetMonitor(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -104,7 +105,7 @@ func updateMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } - if err := services.UpdateMonitor(c.Param("id"), upd); err != nil { + if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -112,7 +113,7 @@ func updateMonitor(c *gin.Context) { } func deleteMonitor(c *gin.Context) { - if err := services.DeleteMonitor(c.Param("id")); err != nil { + if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/server/internal/api/org.go b/server/internal/api/org.go new file mode 100644 index 0000000..0cf374f --- /dev/null +++ b/server/internal/api/org.go @@ -0,0 +1,89 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" + "github.com/mrhid6/vantage/server/internal/services" +) + +func listOrgUsers(c *gin.Context) { + users, err := services.ListUsers(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, users) +} + +func createOrgUser(c *gin.Context) { + var body struct { + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "email required"}) + return + } + if body.Role == "" { + body.Role = "member" + } + u, err := services.CreateUser(auth.OrgID(c), body.Email, body.Password, body.Role, "local") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, u) +} + +func updateOrgUserRole(c *gin.Context) { + var body struct { + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "role required"}) + return + } + if err := services.UpdateUserRole(auth.OrgID(c), c.Param("id"), body.Role); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func deleteOrgUser(c *gin.Context) { + if err := services.DeleteUser(auth.OrgID(c), c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +func getOrgOIDC(c *gin.Context) { + cfg, err := services.GetOrgOIDC(auth.OrgID(c)) + if err != nil { + c.JSON(http.StatusOK, gin.H{"enabled": false}) + return + } + c.JSON(http.StatusOK, cfg) +} + +func putOrgOIDC(c *gin.Context) { + var body struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Enabled bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := services.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"saved": true}) +} diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index 9dacd28..63d31c1 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/services" ) @@ -40,7 +41,7 @@ func secretsReadAuth() gin.HandlerFunc { // (ESO treats 404 as "deleted"). func esoGetGroup(c *gin.Context) { group := c.Param("group") - values, err := services.GetSecretGroupDecrypted(group) + values, err := services.GetSecretGroupDecryptedAny(group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"}) return @@ -53,7 +54,7 @@ func esoGetGroup(c *gin.Context) { } func listSecretGroups(c *gin.Context) { - groups, err := services.ListSecretGroups() + groups, err := services.ListSecretGroups(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -86,17 +87,17 @@ func createSecretGroup(c *gin.Context) { return } } - if err := services.UpsertSecrets(body.Group, body.Values); err != nil { + if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", "))) + services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", "))) c.JSON(http.StatusCreated, gin.H{"group": body.Group}) } func getSecretGroup(c *gin.Context) { group := c.Param("group") - secrets, err := services.GetSecretGroup(group) + secrets, err := services.GetSecretGroup(auth.OrgID(c), group) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -130,11 +131,11 @@ func putSecretGroup(c *gin.Context) { return } } - if err := services.UpsertSecrets(group, values); err != nil { + if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", "))) + services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", "))) c.JSON(http.StatusOK, gin.H{"saved": true}) } @@ -147,33 +148,33 @@ func revealSecret(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - value, err := services.RevealSecret(group, body.Key) + value, err := services.RevealSecret(auth.OrgID(c), group, body.Key) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key)) + services.LogEvent(auth.OrgID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key)) c.JSON(http.StatusOK, gin.H{"value": value}) } func deleteSecretKey(c *gin.Context) { group := c.Param("group") key := c.Param("key") - if err := services.DeleteSecret(group, key); err != nil { + if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group)) + services.LogEvent(auth.OrgID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } func deleteSecretGroup(c *gin.Context) { group := c.Param("group") - if err := services.DeleteSecretGroup(group); err != nil { + if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group)) + services.LogEvent(auth.OrgID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group)) c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -183,6 +184,6 @@ func rotateSecretsToken(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated") + services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated") c.JSON(http.StatusOK, gin.H{"token": token}) } diff --git a/server/internal/api/workflows.go b/server/internal/api/workflows.go index 8ca8248..02b3dee 100644 --- a/server/internal/api/workflows.go +++ b/server/internal/api/workflows.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/server/internal/auth" "github.com/mrhid6/vantage/server/internal/models" "github.com/mrhid6/vantage/server/internal/services" ) @@ -105,9 +106,10 @@ func streamServerRunLog(c *gin.Context) { ctx := c.Request.Context() ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() + orgID := auth.OrgID(c) for { sendNew() - if serverRunTerminal(runID, serverID) { + if serverRunTerminal(orgID, runID, serverID) { sendNew() // final drain _, _ = c.Writer.WriteString("event: done\ndata: end\n\n") flusher.Flush() @@ -122,8 +124,8 @@ func streamServerRunLog(c *gin.Context) { } // serverRunTerminal reports whether the given server-run has reached a terminal status. -func serverRunTerminal(runID, serverID string) bool { - r, err := services.GetRun(runID) +func serverRunTerminal(orgID, runID, serverID string) bool { + r, err := services.GetRun(orgID, runID) if err != nil { return true } @@ -147,7 +149,7 @@ func splitSSE(b []byte) []string { } func listSteps(c *gin.Context) { - steps, err := services.ListSteps() + steps, err := services.ListSteps(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -156,7 +158,7 @@ func listSteps(c *gin.Context) { } func stepUsage(c *gin.Context) { - counts, err := services.StepUsageCounts() + counts, err := services.StepUsageCounts(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -170,12 +172,12 @@ func createStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.CreateStep(s) + out, err := services.CreateStep(auth.OrgID(c), s) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) + services.LogEvent(auth.OrgID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name)) c.JSON(http.StatusCreated, out) } @@ -185,25 +187,25 @@ func updateStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.UpdateStep(c.Param("id"), s); err != nil { + if err := services.UpdateStep(auth.OrgID(c), c.Param("id"), s); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") + services.LogEvent(auth.OrgID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated") c.JSON(http.StatusOK, gin.H{"updated": true}) } func deleteStep(c *gin.Context) { - if err := services.DeleteStep(c.Param("id")); err != nil { + if err := services.DeleteStep(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") + services.LogEvent(auth.OrgID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted") c.JSON(http.StatusOK, gin.H{"deleted": true}) } func exportStep(c *gin.Context) { - b, err := services.ExportStep(c.Param("id")) + b, err := services.ExportStep(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -213,12 +215,12 @@ func exportStep(c *gin.Context) { } func seedDefaults(c *gin.Context) { - created, updated, err := services.SeedDefaultSteps() + created, updated, err := services.SeedDefaultSteps(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) + services.LogEvent(auth.OrgID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated)) c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated}) } @@ -231,12 +233,12 @@ func importStep(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.ImportStepToLibrary(body) + out, err := services.ImportStepToLibrary(auth.OrgID(c), body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) + services.LogEvent(auth.OrgID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name)) c.JSON(http.StatusCreated, out) } @@ -258,7 +260,7 @@ func parseStep(c *gin.Context) { } func listWorkflows(c *gin.Context) { - wfs, err := services.ListWorkflows() + wfs, err := services.ListWorkflows(auth.OrgID(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -272,17 +274,17 @@ func createWorkflow(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - out, err := services.CreateWorkflow(w) + out, err := services.CreateWorkflow(auth.OrgID(c), w) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) + services.LogEvent(auth.OrgID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name)) c.JSON(http.StatusCreated, out) } func getWorkflow(c *gin.Context) { - w, err := services.GetWorkflow(c.Param("id")) + w, err := services.GetWorkflow(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -296,12 +298,12 @@ func updateWorkflow(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := services.UpdateWorkflow(c.Param("id"), w); err != nil { + if err := services.UpdateWorkflow(auth.OrgID(c), c.Param("id"), w); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") - updated, err := services.GetWorkflow(c.Param("id")) + services.LogEvent(auth.OrgID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated") + updated, err := services.GetWorkflow(auth.OrgID(c), c.Param("id")) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -310,21 +312,21 @@ func updateWorkflow(c *gin.Context) { } func deleteWorkflow(c *gin.Context) { - if err := services.DeleteWorkflow(c.Param("id")); err != nil { + if err := services.DeleteWorkflow(auth.OrgID(c), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") + services.LogEvent(auth.OrgID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted") c.JSON(http.StatusOK, gin.H{"deleted": true}) } func runWorkflow(c *gin.Context) { - runID, err := services.TriggerWorkflow(c.Param("id"), actorFromCtx(c)) + runID, err := services.TriggerWorkflow(auth.OrgID(c), c.Param("id"), actorFromCtx(c)) if err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) + services.LogEvent(auth.OrgID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID)) c.JSON(http.StatusAccepted, gin.H{"run_id": runID}) } @@ -335,7 +337,7 @@ func listWorkflowRuns(c *gin.Context) { limit = n } } - runs, err := services.ListRuns(c.Param("id"), limit) + runs, err := services.ListRuns(auth.OrgID(c), c.Param("id"), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -344,7 +346,7 @@ func listWorkflowRuns(c *gin.Context) { } func getRun(c *gin.Context) { - r, err := services.GetRun(c.Param("runId")) + r, err := services.GetRun(auth.OrgID(c), c.Param("runId")) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return @@ -353,10 +355,10 @@ func getRun(c *gin.Context) { } func cancelRun(c *gin.Context) { - if err := services.CancelRun(c.Param("runId")); err != nil { + if err := services.CancelRun(auth.OrgID(c), c.Param("runId")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - services.LogEvent("workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled") + services.LogEvent(auth.OrgID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled") c.JSON(http.StatusOK, gin.H{"cancelled": true}) } diff --git a/server/internal/auth/middleware.go b/server/internal/auth/middleware.go index 4118c56..3f35564 100644 --- a/server/internal/auth/middleware.go +++ b/server/internal/auth/middleware.go @@ -35,6 +35,19 @@ func UserID(c *gin.Context) string { return "" } +func RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + r := Role(c) + for _, want := range roles { + if r == want { + c.Next() + return + } + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"}) + } +} + func Middleware() gin.HandlerFunc { return func(c *gin.Context) { cookie, err := c.Request.Cookie(sessionCookieName) diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index 3351998..f60c461 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -63,13 +63,13 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe } // Agent-generated keys carry no passphrase over the wire (proto has no field). - key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") + key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "") if err != nil { return nil, status.Errorf(codes.Internal, "failed to store key: %v", err) } // Auto-assign to the generating server - if _, err := services.AssignKey(key.KeyID, srv.ServerID); err != nil { + if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil { log.Printf("failed to auto-assign generated key: %v", err) } diff --git a/server/internal/services/audit.go b/server/internal/services/audit.go index 9e2c51b..82ac79b 100644 --- a/server/internal/services/audit.go +++ b/server/internal/services/audit.go @@ -11,11 +11,12 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func LogEvent(eventType, actor, serverID, keyID, details string) { +func LogEvent(orgID, eventType, actor, serverID, keyID, details string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() event := models.AuditEvent{ + OrgID: orgID, EventType: eventType, Actor: actor, ServerID: serverID, @@ -28,7 +29,7 @@ func LogEvent(eventType, actor, serverID, keyID, details string) { } } -func ListAuditEvents(limit int64) ([]models.AuditEvent, error) { +func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -36,7 +37,7 @@ func ListAuditEvents(limit int64) ([]models.AuditEvent, error) { SetSort(bson.D{{Key: "created_at", Value: -1}}). SetLimit(limit) - cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts) + cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"org_id": orgID}, opts) if err != nil { return nil, err } diff --git a/server/internal/services/channels.go b/server/internal/services/channels.go index d37a93b..3f91795 100644 --- a/server/internal/services/channels.go +++ b/server/internal/services/channels.go @@ -13,10 +13,10 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func ListChannels() ([]models.NotificationChannel, error) { +func ListChannels(orgID string) ([]models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("notification_channels").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1})) + cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1})) if err != nil { return nil, err } @@ -27,11 +27,11 @@ func ListChannels() ([]models.NotificationChannel, error) { return out, nil } -func GetChannel(channelID string) (*models.NotificationChannel, error) { +func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() var ch models.NotificationChannel - err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID}).Decode(&ch) + err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}).Decode(&ch) if errors.Is(err, mongo.ErrNoDocuments) { return nil, nil } @@ -59,9 +59,10 @@ func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) { return out, nil } -func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) { +func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) { ctx, cancel := monCtx() defer cancel() + ch.OrgID = orgID ch.ChannelID = uuid.NewString() ch.CreatedAt = time.Now() if ch.Config == nil { @@ -73,23 +74,23 @@ func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, return ch, nil } -func UpdateChannel(channelID string, upd bson.M) error { +func UpdateChannel(orgID, channelID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID}, bson.M{"$set": upd}) + _, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd}) return err } -func DeleteChannel(channelID string) error { +func DeleteChannel(orgID, channelID string) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID}) + _, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}) return err } // TestChannel sends a synthetic alert to verify configuration. -func TestChannel(channelID string) error { - ch, err := GetChannel(channelID) +func TestChannel(orgID, channelID string) error { + ch, err := GetChannel(orgID, channelID) if err != nil { return err } diff --git a/server/internal/services/defaults.go b/server/internal/services/defaults.go index 068c335..65856f7 100644 --- a/server/internal/services/defaults.go +++ b/server/internal/services/defaults.go @@ -52,7 +52,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) { // SeedDefaultSteps upserts default steps from disk keyed on {slug, source}. // Re-sync overwrites default-step content; user steps are never touched. -func SeedDefaultSteps() (created, updated int, err error) { +func SeedDefaultSteps(orgID string) (created, updated int, err error) { steps, err := readDefaultStepFiles() if err != nil { return 0, 0, err @@ -61,7 +61,7 @@ func SeedDefaultSteps() (created, updated int, err error) { defer cancel() col := db.Col("workflow_steps") for _, s := range steps { - filter := bson.M{"slug": s.Slug, "source": "default"} + filter := bson.M{"org_id": orgID, "slug": s.Slug, "source": "default"} set := bson.M{ "name": s.Name, "description": s.Description, @@ -75,6 +75,7 @@ func SeedDefaultSteps() (created, updated int, err error) { res, uerr := col.UpdateOne(ctx, filter, bson.M{ "$set": set, "$setOnInsert": bson.M{ + "org_id": orgID, "step_id": uuid.New().String(), "slug": s.Slug, "source": "default", diff --git a/server/internal/services/keys.go b/server/internal/services/keys.go index e8d19c7..a233fa5 100644 --- a/server/internal/services/keys.go +++ b/server/internal/services/keys.go @@ -36,8 +36,9 @@ func setKeyMeta(k *models.Key) { k.HasPassphrase = k.PassphraseEncrypted != "" } -func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { +func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) { key := &models.Key{ + OrgID: orgID, KeyID: uuid.NewString(), Label: label, PublicKey: publicKey, @@ -71,12 +72,12 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passph return key, nil } -func GetKey(keyID string) (*models.Key, error) { +func GetKey(orgID, keyID string) (*models.Key, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key) + err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key) if err != nil { return nil, err } @@ -84,12 +85,12 @@ func GetKey(keyID string) (*models.Key, error) { return &key, nil } -func GetPrivateKey(keyID string) (string, error) { +func GetPrivateKey(orgID, keyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil { return "", err } if key.PrivateKeyEncrypted == "" { @@ -99,7 +100,8 @@ func GetPrivateKey(keyID string) (string, error) { } // GetPassphrase returns the decrypted passphrase for a key, or an empty string -// if the key has none stored. +// if the key has none stored. Agent-path (keyed by unique key_id from an +// assignment lookup) — no org filter. func GetPassphrase(keyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -119,11 +121,11 @@ type KeyWithCount struct { AssignedCount int `bson:"-" json:"assigned_count"` } -func ListKeys() ([]KeyWithCount, error) { +func ListKeys(orgID string) ([]KeyWithCount, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("keys").Find(ctx, bson.M{}) + cursor, err := db.Col("keys").Find(ctx, bson.M{"org_id": orgID}) if err != nil { return nil, err } @@ -138,6 +140,7 @@ func ListKeys() ([]KeyWithCount, error) { for _, k := range keys { setKeyMeta(&k) count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{ + "org_id": orgID, "key_id": k.KeyID, "revoked_at": nil, }) @@ -146,19 +149,19 @@ func ListKeys() ([]KeyWithCount, error) { return result, nil } -func DeleteKey(keyID string) error { +func DeleteKey(orgID, keyID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil { return err } - if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil { + if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil { return err } - if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil { + if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil { return err } @@ -168,13 +171,14 @@ func DeleteKey(keyID string) error { return nil } -func AssignKey(keyID, serverID string) (*models.Assignment, error) { +func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // Check if already assigned and active var existing models.Assignment err := db.Col("assignments").FindOne(ctx, bson.M{ + "org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil, @@ -184,6 +188,7 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) { } a := &models.Assignment{ + OrgID: orgID, KeyID: keyID, ServerID: serverID, AssignedAt: time.Now(), @@ -195,23 +200,23 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) { return a, nil } -func RevokeAssignment(keyID, serverID string) error { +func RevokeAssignment(orgID, keyID, serverID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() now := time.Now() _, err := db.Col("assignments").UpdateOne(ctx, - bson.M{"key_id": keyID, "server_id": serverID, "revoked_at": nil}, + bson.M{"org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil}, bson.M{"$set": bson.M{"revoked_at": now}}, ) return err } -func GetAssignmentsForKey(keyID string) ([]models.Assignment, error) { +func GetAssignmentsForKey(orgID, keyID string) ([]models.Assignment, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID, "revoked_at": nil}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID, "revoked_at": nil}) if err != nil { return nil, err } @@ -229,11 +234,11 @@ type AssignmentWithServer struct { Server *models.Server `json:"server,omitempty"` } -func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) { +func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID}) if err != nil { return nil, err } @@ -248,7 +253,7 @@ func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) { for _, a := range assignments { item := AssignmentWithServer{Assignment: a} var srv models.Server - if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID}).Decode(&srv); err == nil { + if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "org_id": orgID}).Decode(&srv); err == nil { item.Server = &srv } result = append(result, item) @@ -261,11 +266,11 @@ type AssignmentWithKey struct { Key *models.Key `json:"key,omitempty"` } -func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, error) { +func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKey, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("assignments").Find(ctx, bson.M{"server_id": serverID}) + cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "server_id": serverID}) if err != nil { return nil, err } @@ -279,7 +284,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro result := make([]AssignmentWithKey, 0, len(assignments)) for _, a := range assignments { var key models.Key - if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil { + if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": orgID}).Decode(&key); err != nil { continue } setKeyMeta(&key) diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index b72d146..aaf196a 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -36,10 +36,10 @@ func SpecFor(m *models.Monitor) checker.Spec { } } -func ListMonitors() ([]models.Monitor, error) { +func ListMonitors(orgID string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() - cur, err := db.Col("monitors").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1})) + cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1})) if err != nil { return nil, err } @@ -51,6 +51,8 @@ func ListMonitors() ([]models.Monitor, error) { } // ListMonitorsForRunner returns enabled monitors whose Runner matches runner. +// Agent/scheduler path — a cross-org sweep (mirrors MarkOfflineServers), so it +// intentionally has no org filter. func ListMonitorsForRunner(runner string) ([]models.Monitor, error) { ctx, cancel := monCtx() defer cancel() @@ -65,7 +67,24 @@ func ListMonitorsForRunner(runner string) ([]models.Monitor, error) { return out, nil } -func GetMonitor(monitorID string) (*models.Monitor, error) { +// GetMonitor looks up a monitor scoped to an org (handler/session use). +func GetMonitor(orgID, monitorID string) (*models.Monitor, error) { + ctx, cancel := monCtx() + defer cancel() + var m models.Monitor + err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}).Decode(&m) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +// getMonitorByID looks up a monitor by its unique monitor_id with no org +// filter. For agent/scheduler use only (IngestResult), which has no session. +func getMonitorByID(monitorID string) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() var m models.Monitor @@ -79,9 +98,10 @@ func GetMonitor(monitorID string) (*models.Monitor, error) { return &m, nil } -func CreateMonitor(m *models.Monitor) (*models.Monitor, error) { +func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) { ctx, cancel := monCtx() defer cancel() + m.OrgID = orgID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() if m.IntervalSec <= 0 { @@ -100,17 +120,17 @@ func CreateMonitor(m *models.Monitor) (*models.Monitor, error) { return m, nil } -func UpdateMonitor(monitorID string, upd bson.M) error { +func UpdateMonitor(orgID, monitorID string, upd bson.M) error { ctx, cancel := monCtx() defer cancel() - _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": upd}) + _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd}) return err } -func DeleteMonitor(monitorID string) error { +func DeleteMonitor(orgID, monitorID string) error { ctx, cancel := monCtx() defer cancel() - if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID}); err != nil { + if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}); err != nil { return err } db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID}) @@ -161,7 +181,7 @@ func IngestResult(monitorID string, res checker.Result) error { ctx, cancel := monCtx() defer cancel() - m, err := GetMonitor(monitorID) + m, err := getMonitorByID(monitorID) if err != nil || m == nil { return err } @@ -266,5 +286,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) { } }(ch) } - _ = UpdateMonitor(m.MonitorID, bson.M{"state.last_notified_at": time.Now()}) + _ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}) } diff --git a/server/internal/services/orgs.go b/server/internal/services/orgs.go index 6eaf6ca..45f781d 100644 --- a/server/internal/services/orgs.go +++ b/server/internal/services/orgs.go @@ -39,6 +39,27 @@ func GetOrgBySlug(slug string) (*models.Org, error) { return &o, nil } +// ListOrgIDs returns the org_id of every organization. Used by startup tasks +// (e.g. seeding default workflow steps) that must run once per org. +func ListOrgIDs() ([]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cursor, err := db.Col("orgs").Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + var orgs []models.Org + if err := cursor.All(ctx, &orgs); err != nil { + return nil, err + } + ids := make([]string, 0, len(orgs)) + for _, o := range orgs { + ids = append(ids, o.OrgID) + } + return ids, nil +} + func CreateOrg(name string) (*models.Org, error) { base := Slugify(name) if len(base) < 3 { diff --git a/server/internal/services/secrets.go b/server/internal/services/secrets.go index de05a69..a1a0e97 100644 --- a/server/internal/services/secrets.go +++ b/server/internal/services/secrets.go @@ -27,11 +27,12 @@ func EnsureSecretIndexes() error { // ListSecretGroups returns a summary of every group with its key count and // most recent update time. -func ListSecretGroups() ([]models.GroupSummary, error) { +func ListSecretGroups(orgID string) ([]models.GroupSummary, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.D{{Key: "org_id", Value: orgID}}}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$group"}, {Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}}, @@ -68,11 +69,11 @@ func ListSecretGroups() ([]models.GroupSummary, error) { // GetSecretGroup returns the keys within a group, sorted by key name, without // decrypted values. -func GetSecretGroup(group string) ([]models.Secret, error) { +func GetSecretGroup(orgID, group string) ([]models.Secret, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group}, + cursor, err := db.Col("secrets").Find(ctx, bson.M{"org_id": orgID, "group": group}, options.Find().SetSort(bson.D{{Key: "key", Value: 1}})) if err != nil { return nil, err @@ -87,9 +88,11 @@ func GetSecretGroup(group string) ([]models.Secret, error) { } // GetSecretGroupDecrypted returns a flat map of key → plaintext value for a -// group. Used by the ESO read endpoint. -func GetSecretGroupDecrypted(group string) (map[string]string, error) { - docs, err := GetSecretGroup(group) +// group. Used by the ESO read endpoint, which authenticates via a bearer +// token rather than a session — org resolution for that path is a known gap, +// tracked separately; the token is currently global rather than per-org. +func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) { + docs, err := GetSecretGroup(orgID, group) if err != nil { return nil, err } @@ -104,13 +107,43 @@ func GetSecretGroupDecrypted(group string) (map[string]string, error) { return result, nil } +// GetSecretGroupDecryptedAny is the ESO-bearer-token read path: it has no +// session/org context (the read token is currently global, not per-org), so +// it looks up the group across all orgs. This mirrors pre-multi-tenant +// behavior; scoping the ESO token to an org is tracked as a follow-up. +func GetSecretGroupDecryptedAny(group string) (map[string]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group}, + options.Find().SetSort(bson.D{{Key: "key", Value: 1}})) + if err != nil { + return nil, err + } + defer cursor.Close(ctx) + + var docs []models.Secret + if err := cursor.All(ctx, &docs); err != nil { + return nil, err + } + result := make(map[string]string, len(docs)) + for _, doc := range docs { + val, err := decryptString(doc.EncryptedValue) + if err != nil { + return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err) + } + result[doc.Key] = val + } + return result, nil +} + // RevealSecret returns the decrypted value of a single key. -func RevealSecret(group, key string) (string, error) { +func RevealSecret(orgID, group, key string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var doc models.Secret - err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc) + err := db.Col("secrets").FindOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}).Decode(&doc) if err == mongo.ErrNoDocuments { return "", fmt.Errorf("secret not found") } @@ -121,7 +154,7 @@ func RevealSecret(group, key string) (string, error) { } // UpsertSecrets encrypts and writes each key/value pair into the group. -func UpsertSecrets(group string, values map[string]string) error { +func UpsertSecrets(orgID, group string, values map[string]string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -131,8 +164,9 @@ func UpsertSecrets(group string, values map[string]string) error { return fmt.Errorf("encrypt %s: %w", key, err) } _, err = db.Col("secrets").UpdateOne(ctx, - bson.M{"group": group, "key": key}, + bson.M{"org_id": orgID, "group": group, "key": key}, bson.M{"$set": bson.M{ + "org_id": orgID, "encrypted_value": encrypted, "updated_at": time.Now(), }}, @@ -156,19 +190,19 @@ func SortedKeys(m map[string]string) []string { } // DeleteSecret removes a single key from a group. -func DeleteSecret(group, key string) error { +func DeleteSecret(orgID, group, key string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key}) + _, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}) return err } // DeleteSecretGroup removes an entire group and all its keys. -func DeleteSecretGroup(group string) error { +func DeleteSecretGroup(orgID, group string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group}) + _, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group}) return err } diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index a6fff8e..3900836 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -29,13 +29,14 @@ func HashToken(token string) string { return hex.EncodeToString(sum[:]) } -func CreateServer() (*models.Server, string, error) { +func CreateServer(orgID string) (*models.Server, string, error) { token, err := generateToken(32) if err != nil { return nil, "", err } expires := time.Now().Add(time.Hour) s := &models.Server{ + OrgID: orgID, ServerID: uuid.NewString(), PreRegToken: token, PreRegExpires: &expires, @@ -52,7 +53,22 @@ func CreateServer() (*models.Server, string, error) { return s, token, nil } -func GetServer(serverID string) (*models.Server, error) { +// GetServer looks up a server scoped to an org (handler/session use). +func GetServer(orgID, serverID string) (*models.Server, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var s models.Server + err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s) + if err != nil { + return nil, err + } + return &s, nil +} + +// getServerByID looks up a server by its unique server_id with no org filter. +// For agent/internal use only (e.g. workflow runner resolving org from a run). +func getServerByID(serverID string) (*models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -214,12 +230,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error { return err } -func ListServers() ([]models.Server, error) { +func ListServers(orgID string) ([]models.Server, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}) - cursor, err := db.Col("servers").Find(ctx, bson.M{}, opts) + cursor, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, opts) if err != nil { return nil, err } @@ -232,11 +248,11 @@ func ListServers() ([]models.Server, error) { return servers, nil } -func DeleteServer(serverID string) error { +func DeleteServer(orgID, serverID string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID}) + _, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}) if err != nil { return err } @@ -293,7 +309,7 @@ func MarkOfflineServers() error { } for _, s := range goingOffline { - LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress)) + LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress)) if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" { go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress) } diff --git a/server/internal/services/stepio.go b/server/internal/services/stepio.go index ad6f12e..e8f9ea9 100644 --- a/server/internal/services/stepio.go +++ b/server/internal/services/stepio.go @@ -66,19 +66,19 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) { } // ImportStepToLibrary parses a doc and persists it as a new user library step. -func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) { +func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) { s, err := ParseStepDoc(b) if err != nil { return nil, err } - return CreateStep(s) + return CreateStep(orgID, s) } // ExportStep loads a library step and marshals it to a portable doc. -func ExportStep(stepID string) ([]byte, error) { +func ExportStep(orgID, stepID string) ([]byte, error) { ctx, cancel := wfCtx() defer cancel() - s, err := getStep(ctx, stepID) + s, err := getStep(ctx, orgID, stepID) if err != nil { return nil, err } diff --git a/server/internal/services/workflow_runner.go b/server/internal/services/workflow_runner.go index 72d5d73..23f3bda 100644 --- a/server/internal/services/workflow_runner.go +++ b/server/internal/services/workflow_runner.go @@ -19,8 +19,8 @@ const stepDispatchGrace = 15 * time.Second // TriggerWorkflow snapshots the workflow, creates a run doc, and starts a // background goroutine per target server (parallel fan-out). Returns run_id. -func TriggerWorkflow(workflowID, actor string) (string, error) { - wf, err := GetWorkflow(workflowID) +func TriggerWorkflow(orgID, workflowID, actor string) (string, error) { + wf, err := GetWorkflow(orgID, workflowID) if err != nil { return "", err } @@ -33,18 +33,19 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { // Reject a concurrent run of the same workflow. ctx, cancel := wfCtx() - running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"}) + running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"}) cancel() if running.Err() == nil { return "", fmt.Errorf("workflow already has a run in progress") } - resolved, err := resolveSteps(wf) + resolved, err := resolveSteps(orgID, wf) if err != nil { return "", err } run := models.WorkflowRun{ + OrgID: orgID, RunID: uuid.New().String(), WorkflowID: workflowID, Name: wf.Name, @@ -56,7 +57,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { } for _, sid := range wf.TargetServerIDs { hostname := sid - if s, e := GetServer(sid); e == nil { + if s, e := getServerByID(sid); e == nil { hostname = s.Hostname } sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}} @@ -78,7 +79,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) { // resolveSteps freezes each workflow step ref into a ResolvedStep by loading the // library step and applying overrides. -func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { +func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) { ctx, cancel := wfCtx() defer cancel() out := make([]models.ResolvedStep, 0, len(wf.Steps)) @@ -87,7 +88,7 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) { out = append(out, resolveInlineStep(ref)) continue } - lib, err := getStep(ctx, ref.StepID) + lib, err := getStep(ctx, orgID, ref.StepID) if err != nil { return nil, err } @@ -158,14 +159,14 @@ func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep { // executeRun fans out one goroutine per server run and waits for all to finish. func executeRun(runID string) { - run, err := GetRun(runID) + run, err := getRunByID(runID) if err != nil { return } done := make(chan int, len(run.ServerRuns)) for i := range run.ServerRuns { go func(idx int) { - runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID) + runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID) done <- idx }(i) } @@ -174,7 +175,7 @@ func executeRun(runID string) { } // Aggregate status. - final, _ := GetRun(runID) + final, _ := getRunByID(runID) status := "success" for _, sr := range final.ServerRuns { if sr.Status == "failed" { @@ -190,7 +191,7 @@ func executeRun(runID string) { // runServer executes the resolved steps sequentially on one server, threading // output env forward and applying per-step failure policy. -func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { +func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) { now := time.Now() setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now}) @@ -218,7 +219,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s } // Merge secrets into command env (kept out of persisted logs). - secretVals := resolveSecrets(step.SecretRefs) + secretVals := resolveSecrets(orgID, step.SecretRefs) for k, v := range secretVals { allSecrets[k] = v } @@ -366,7 +367,7 @@ func expandVars(v string, lookup map[string]string) string { }) } -func resolveSecrets(refs []string) map[string]string { +func resolveSecrets(orgID string, refs []string) map[string]string { out := map[string]string{} for _, ref := range refs { // ref format "group/KEY"; resolve via RevealSecret. @@ -374,7 +375,7 @@ func resolveSecrets(refs []string) map[string]string { if len(parts) != 2 { continue } - if v, err := RevealSecret(parts[0], parts[1]); err == nil { + if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil { out[parts[1]] = v } } @@ -403,7 +404,7 @@ func setServerRun(runID string, srvIdx int, set bson.M) { // serverIDAt returns the server_id at an index (positional operator needs a match). func serverIDAt(runID string, srvIdx int) string { - r, err := GetRun(runID) + r, err := getRunByID(runID) if err != nil || srvIdx >= len(r.ServerRuns) { return "" } @@ -467,7 +468,10 @@ func updateStep(runID, serverID string, order int, set bson.M) { // ---- reads ---- -func GetRun(runID string) (*models.WorkflowRun, error) { +// getRunByID looks up a run by its unique run_id with no org filter. For +// agent/internal run-execution use only (executeRun/runServer, etc.), which +// don't have a session and instead resolve org from the run doc itself. +func getRunByID(runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() var r models.WorkflowRun @@ -478,10 +482,22 @@ func GetRun(runID string) (*models.WorkflowRun, error) { return &r, err } -func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) { +// GetRun looks up a run scoped to an org (handler/session use). +func GetRun(orgID, runID string) (*models.WorkflowRun, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID}, + var r models.WorkflowRun + err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r) + if err == mongo.ErrNoDocuments { + return nil, fmt.Errorf("run not found") + } + return &r, err +} + +func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, error) { + ctx, cancel := wfCtx() + defer cancel() + cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID}, options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit)) if err != nil { return nil, err @@ -494,12 +510,12 @@ func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) { return runs, nil } -func CancelRun(runID string) error { +func CancelRun(orgID, runID string) error { now := time.Now() ctx, cancel := wfCtx() defer cancel() _, err := db.Col("workflow_runs").UpdateOne(ctx, - bson.M{"run_id": runID, "status": "running"}, + bson.M{"org_id": orgID, "run_id": runID, "status": "running"}, bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}}) return err } diff --git a/server/internal/services/workflows.go b/server/internal/services/workflows.go index 1a55c1d..f3f1820 100644 --- a/server/internal/services/workflows.go +++ b/server/internal/services/workflows.go @@ -45,10 +45,10 @@ func EnsureWorkflowIndexes() error { // ---- Steps ---- -func ListSteps() ([]models.WorkflowStep, error) { +func ListSteps(orgID string) ([]models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflow_steps").Find(ctx, bson.M{}, + cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) if err != nil { return nil, err @@ -63,10 +63,10 @@ func ListSteps() ([]models.WorkflowStep, error) { // StepUsageCounts returns, per library step_id, the number of distinct // workflows that reference it. Inline steps have no step_id and are ignored. -func StepUsageCounts() (map[string]int, error) { +func StepUsageCounts(orgID string) (map[string]int, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{}) + cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID}) if err != nil { return nil, err } @@ -89,9 +89,10 @@ func StepUsageCounts() (map[string]int, error) { return counts, nil } -func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { +func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) { ctx, cancel := wfCtx() defer cancel() + s.OrgID = orgID s.StepID = uuid.New().String() s.CreatedAt = time.Now() s.UpdatedAt = s.CreatedAt @@ -111,10 +112,10 @@ func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) { return &s, nil } -func UpdateStep(stepID string, s models.WorkflowStep) error { +func UpdateStep(orgID, stepID string, s models.WorkflowStep) error { ctx, cancel := wfCtx() defer cancel() - _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{ + _, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{ "name": s.Name, "description": s.Description, "interpreter": s.Interpreter, @@ -127,14 +128,14 @@ func UpdateStep(stepID string, s models.WorkflowStep) error { return err } -func DeleteStep(stepID string) error { +func DeleteStep(orgID, stepID string) error { ctx, cancel := wfCtx() defer cancel() - if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil { + if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil { return err } // Cascade: remove this step from every workflow that references it, re-sequencing orders. - cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID}) + cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID}) if err != nil { return err } @@ -164,9 +165,9 @@ func DeleteStep(stepID string) error { return nil } -func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) { +func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) { var s models.WorkflowStep - err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s) + err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("step %s not found", stepID) } @@ -175,10 +176,10 @@ func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) { // ---- Workflows ---- -func ListWorkflows() ([]models.Workflow, error) { +func ListWorkflows(orgID string) ([]models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() - cur, err := db.Col("workflows").Find(ctx, bson.M{}, + cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.D{{Key: "name", Value: 1}})) if err != nil { return nil, err @@ -191,20 +192,21 @@ func ListWorkflows() ([]models.Workflow, error) { return wfs, nil } -func GetWorkflow(id string) (*models.Workflow, error) { +func GetWorkflow(orgID, id string) (*models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() var w models.Workflow - err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w) + err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}).Decode(&w) if err == mongo.ErrNoDocuments { return nil, fmt.Errorf("workflow not found") } return &w, err } -func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { +func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) { ctx, cancel := wfCtx() defer cancel() + w.OrgID = orgID w.WorkflowID = uuid.New().String() w.CreatedAt = time.Now() w.UpdatedAt = w.CreatedAt @@ -224,14 +226,14 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) { return &w, nil } -func UpdateWorkflow(id string, w models.Workflow) error { +func UpdateWorkflow(orgID, id string, w models.Workflow) error { ctx, cancel := wfCtx() defer cancel() if err := ValidateWorkflow(w); err != nil { return err } normalizeInlineSteps(&w) - _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{ + _, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{ "name": w.Name, "target_server_ids": w.TargetServerIDs, "steps": w.Steps, @@ -263,9 +265,9 @@ func normalizeInlineSteps(w *models.Workflow) { } } -func DeleteWorkflow(id string) error { +func DeleteWorkflow(orgID, id string) error { ctx, cancel := wfCtx() defer cancel() - _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id}) + _, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}) return err }