feat(server): org-scope service layer + handlers + org admin API

Threads org_id through every admin-facing service function (servers, keys,
assignments, secrets, workflows/steps/runs, monitors, channels, audit),
adds RequireRole middleware, and wires /api/org user + OIDC management
routes. Agent/scheduler paths keep unique-key signatures and resolve org
from the loaded record; internal-only helpers (getServerByID,
getRunByID, getMonitorByID) preserve those call sites.
This commit is contained in:
2026-07-21 16:56:39 +01:00
parent d0ed9885e7
commit 850aa0ed05
21 changed files with 451 additions and 209 deletions
+4 -3
View File
@@ -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
}
+12 -11
View File
@@ -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
}
+3 -2
View File
@@ -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",
+28 -23
View File
@@ -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)
+30 -10
View File
@@ -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()})
}
+21
View File
@@ -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 {
+48 -14
View File
@@ -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
}
+23 -7
View File
@@ -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)
}
+4 -4
View File
@@ -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
}
+36 -20
View File
@@ -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
}
+23 -21
View File
@@ -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
}