From d7bad9865addc134f8f9f29ff4248092b4bf9d89 Mon Sep 17 00:00:00 2001 From: Abhishek K Date: Wed, 21 May 2025 13:13:20 +0530 Subject: [PATCH] NET-2014: Audit Logging (#3455) * feat: api access tokens * revoke all user tokens * redefine access token api routes, add auto egress option to enrollment keys * add server settings apis, add db table for settigs * handle server settings updates * switch to using settings from DB * fix sever settings migration * revet force migration for settings * fix server settings database write * egress model * fix revoked tokens to be unauthorized * update egress model * remove unused functions * convert access token to sql schema * switch access token to sql schema * fix merge conflicts * fix server settings types * bypass basic auth setting for super admin * add TODO comment * setup api handlers for egress revamp * use single DB, fix update nat boolean field * extend validaiton checks for egress ranges * add migration to convert to new egress model * fix panic interface conversion * publish peer update on settings update * revoke token generated by an user * add user token creation restriction by user role * add forbidden check for access token creation * revoke user token when group or role is changed * add default group to admin users on update * chore(go): import style changes from migration branch; 1. Singular file names for table schema. 2. No table name method. 3. Use .Model instead of .Table. 4. No unnecessary tagging. * remove nat check on egress gateway request * Revert "remove nat check on egress gateway request" This reverts commit 0aff12a189828fc4ccb4594adf7a3eb8772560f2. * remove nat check on egress gateway request * feat(go): add db middleware; * feat(go): restore method; * feat(go): add user access token schema; * add inet gw status to egress model * fetch node ids in the tag, add inet gw info clients * add inet gw info to node from egress list * add migration logic internet gws * create default acl policies * add egress info * add egress TODO * add egress TODO * fix user auth api: * add reference id to acl policy * add egress response from DB * publish peer update on egress changes * re initalise oauth and email config * set verbosity * normalise cidr on egress req * add egress id to acl group * change acls to use egress id * resolve merge conflicts * fix egress reference errors * move egress model to schema * add api context to DB * sync auto update settings with hosts * sync auto update settings with hosts * check acl for egress node * check for egress policy in the acl dst groups * fix acl rules for egress policies with new models * add status to egress model * fix inet node func * mask secret and convert jwt duration to minutes * enable egress policies on creation * convert jwt duration to minutes * add relevant ranges to inet egress * skip non active egress routes * resolve merge conflicts * fix static check * notify peers after settings update * define schema for activity, add api handler to list network activity * setup event channel and logger * setup event logger, add event for user login * change activity model to event * add api error constants * add logout event * log user crud events * add login events for oauth * add user related events * log events for invites and user approvals * order user activity event by timestamp * fix logout api * add user and network events api, add addtional events triggers * add filters to all events api * fix events filter * add diff to event logs * update user logout api * log settigns updates * log events for network and host updates * check for diff on events * log host del event * add user loc info to desktop app connection events * fix authorize middleware check * add gateway events * resolve merge conflicts --------- Co-authored-by: Vishal Dalwadi --- controllers/acls.go | 52 ++++++++++ controllers/egress.go | 53 ++++++++++ controllers/enrollmentkeys.go | 77 ++++++++++++++- controllers/ext_client.go | 21 ++++ controllers/gateway.go | 42 +++++++- controllers/hosts.go | 84 +++++++++++++++- controllers/network.go | 36 ++++++- controllers/node.go | 23 ++++- controllers/server.go | 19 ++++ controllers/tags.go | 54 ++++++++++- controllers/user.go | 176 ++++++++++++++++++++++++++++++++-- db/db.go | 12 +++ go.mod | 1 + go.sum | 2 + logic/errors.go | 22 +++-- logic/extpeers.go | 19 ++++ logic/telemetry.go | 2 + models/events.go | 74 ++++++++++++++ pro/auth/azure-ad.go | 17 +++- pro/auth/github.go | 17 +++- pro/auth/google.go | 18 ++++ pro/auth/oidc.go | 17 +++- pro/controllers/events.go | 114 ++++++++++++++++++++++ pro/controllers/users.go | 176 +++++++++++++++++++++++++++++++++- pro/initialize.go | 3 + pro/logic/events.go | 47 +++++++++ schema/event.go | 55 +++++++++++ schema/models.go | 1 + 28 files changed, 1203 insertions(+), 31 deletions(-) create mode 100644 models/events.go create mode 100644 pro/controllers/events.go create mode 100644 pro/logic/events.go create mode 100644 schema/event.go diff --git a/controllers/acls.go b/controllers/acls.go index a89d20eb..f97691ad 100644 --- a/controllers/acls.go +++ b/controllers/acls.go @@ -268,6 +268,22 @@ func createAcl(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: acl.ID, + Name: acl.Name, + Type: models.AclSub, + }, + NetworkID: acl.NetworkID, + Origin: models.Dashboard, + }) go mq.PublishPeerUpdate(true) logic.ReturnSuccessResponseWithJson(w, r, acl, "created acl successfully") } @@ -310,6 +326,26 @@ func updateAcl(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) return } + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: acl.ID, + Name: acl.Name, + Type: models.AclSub, + }, + Diff: models.Diff{ + Old: acl, + New: updateAcl.Acl, + }, + NetworkID: acl.NetworkID, + Origin: models.Dashboard, + }) go mq.PublishPeerUpdate(true) logic.ReturnSuccessResponse(w, r, "updated acl "+acl.Name) } @@ -341,6 +377,22 @@ func deleteAcl(w http.ResponseWriter, r *http.Request) { logic.FormatError(errors.New("cannot delete default policy"), "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: acl.ID, + Name: acl.Name, + Type: models.AclSub, + }, + NetworkID: acl.NetworkID, + Origin: models.Dashboard, + }) go mq.PublishPeerUpdate(true) logic.ReturnSuccessResponse(w, r, "deleted acl "+acl.Name) } diff --git a/controllers/egress.go b/controllers/egress.go index 90e206dc..db85b3e4 100644 --- a/controllers/egress.go +++ b/controllers/egress.go @@ -85,6 +85,22 @@ func createEgress(w http.ResponseWriter, r *http.Request) { ) return } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: e.ID, + Name: e.Name, + Type: models.EgressSub, + }, + NetworkID: models.NetworkID(e.Network), + Origin: models.Dashboard, + }) // for nodeID := range e.Nodes { // node, err := logic.GetNodeByID(nodeID) // if err != nil { @@ -174,6 +190,25 @@ func updateEgress(w http.ResponseWriter, r *http.Request) { if req.Status != e.Status { updateStatus = true } + event := &models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: e.ID, + Name: e.Name, + Type: models.EgressSub, + }, + Diff: models.Diff{ + Old: e, + }, + NetworkID: models.NetworkID(e.Network), + Origin: models.Dashboard, + } e.Nodes = make(datatypes.JSONMap) e.Tags = make(datatypes.JSONMap) for nodeID, metric := range req.Nodes { @@ -211,6 +246,8 @@ func updateEgress(w http.ResponseWriter, r *http.Request) { e.Status = req.Status e.UpdateEgressStatus(db.WithContext(context.TODO())) } + event.Diff.New = e + logic.LogEvent(event) go mq.PublishPeerUpdate(false) logic.ReturnSuccessResponseWithJson(w, r, e, "updated egress resource") } @@ -237,6 +274,22 @@ func deleteEgress(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: e.ID, + Name: e.Name, + Type: models.EgressSub, + }, + NetworkID: models.NetworkID(e.Network), + Origin: models.Dashboard, + }) // delete related acl policies acls := logic.ListAcls() for _, acl := range acls { diff --git a/controllers/enrollmentkeys.go b/controllers/enrollmentkeys.go index 313736a2..b8cae127 100644 --- a/controllers/enrollmentkeys.go +++ b/controllers/enrollmentkeys.go @@ -72,12 +72,32 @@ func getEnrollmentKeys(w http.ResponseWriter, r *http.Request) { func deleteEnrollmentKey(w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) keyID := params["keyID"] - err := logic.DeleteEnrollmentKey(keyID, false) + key, err := logic.GetEnrollmentKey(keyID) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } + err = logic.DeleteEnrollmentKey(keyID, false) if err != nil { logger.Log(0, r.Header.Get("user"), "failed to remove enrollment key: ", err.Error()) logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: keyID, + Name: key.Tags[0], + Type: models.EnrollmentKeySub, + }, + Origin: models.Dashboard, + }) logger.Log(2, r.Header.Get("user"), "deleted enrollment key", keyID) w.WriteHeader(http.StatusOK) } @@ -173,6 +193,21 @@ func createEnrollmentKey(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: newEnrollmentKey.Value, + Name: newEnrollmentKey.Tags[0], + Type: models.EnrollmentKeySub, + }, + Origin: models.Dashboard, + }) logger.Log(2, r.Header.Get("user"), "created enrollment key") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(newEnrollmentKey) @@ -208,6 +243,7 @@ func updateEnrollmentKey(w http.ResponseWriter, r *http.Request) { return } } + currKey, _ := logic.GetEnrollmentKey(keyId) newEnrollmentKey, err := logic.UpdateEnrollmentKey(keyId, relayId, enrollmentKeyBody.Groups) if err != nil { @@ -221,7 +257,25 @@ func updateEnrollmentKey(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } - + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: newEnrollmentKey.Value, + Name: newEnrollmentKey.Tags[0], + Type: models.EnrollmentKeySub, + }, + Diff: models.Diff{ + Old: currKey, + New: newEnrollmentKey, + }, + Origin: models.Dashboard, + }) slog.Info("updated enrollment key", "id", keyId) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(newEnrollmentKey) @@ -355,6 +409,25 @@ func handleHostRegister(w http.ResponseWriter, r *http.Request) { ServerConf: server, RequestedHost: newHost, } + for _, netID := range enrollmentKey.Networks { + logic.LogEvent(&models.Event{ + Action: models.JoinHostToNet, + Source: models.Subject{ + ID: enrollmentKey.Value, + Name: enrollmentKey.Tags[0], + Type: models.EnrollmentKeySub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: newHost.ID.String(), + Name: newHost.Name, + Type: models.DeviceSub, + }, + NetworkID: models.NetworkID(netID), + Origin: models.Dashboard, + }) + } + logger.Log(0, newHost.Name, newHost.ID.String(), "registered with Netmaker") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(&response) diff --git a/controllers/ext_client.go b/controllers/ext_client.go index ad8b51aa..5c406ee8 100644 --- a/controllers/ext_client.go +++ b/controllers/ext_client.go @@ -799,6 +799,27 @@ func createExtClient(w http.ResponseWriter, r *http.Request) { "clientid", extclient.ClientID, ) + if extclient.RemoteAccessClientID != "" { + // if created by user from client app, log event + logic.LogEvent(&models.Event{ + Action: models.Connect, + Source: models.Subject{ + ID: userName, + Name: userName, + Type: models.UserSub, + }, + TriggeredBy: userName, + Target: models.Subject{ + ID: extclient.Network, + Name: extclient.Network, + Type: models.NetworkSub, + Info: extclient, + }, + NetworkID: models.NetworkID(extclient.Network), + Origin: models.ClientApp, + }) + } + w.WriteHeader(http.StatusOK) go func() { if err := logic.SetClientDefaultACLs(&extclient); err != nil { diff --git a/controllers/gateway.go b/controllers/gateway.go index 7543a9ac..cdfc1a69 100644 --- a/controllers/gateway.go +++ b/controllers/gateway.go @@ -39,6 +39,11 @@ func createGateway(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) return } + host, err := logic.GetHost(node.HostID.String()) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } var req models.CreateGwReq err = json.NewDecoder(r.Body).Decode(&req) if err != nil { @@ -89,7 +94,21 @@ func createGateway(w http.ResponseWriter, r *http.Request) { ) logic.GetNodeStatus(&relayNode, false) apiNode := relayNode.ConvertToAPINode() - + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: node.ID.String(), + Name: host.Name, + Type: models.GatewaySub, + }, + Origin: models.Dashboard, + }) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(apiNode) go func() { @@ -138,6 +157,11 @@ func deleteGateway(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + host, err := logic.GetHost(node.HostID.String()) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } node.IsGw = false logic.UpsertNode(&node) logger.Log(1, r.Header.Get("user"), "deleted gw", nodeid, "on network", netid) @@ -200,7 +224,21 @@ func deleteGateway(w http.ResponseWriter, r *http.Request) { } }() - + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: node.ID.String(), + Name: host.Name, + Type: models.GatewaySub, + }, + Origin: models.Dashboard, + }) logic.GetNodeStatus(&node, false) apiNode := node.ConvertToAPINode() logger.Log(1, r.Header.Get("user"), "deleted ingress gateway", nodeid) diff --git a/controllers/hosts.go b/controllers/hosts.go index 5bc71857..616db0d7 100644 --- a/controllers/hosts.go +++ b/controllers/hosts.go @@ -294,7 +294,25 @@ func updateHost(w http.ResponseWriter, r *http.Request) { } } }() - + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: currHost.ID.String(), + Name: newHost.Name, + Type: models.DeviceSub, + }, + Diff: models.Diff{ + Old: currHost, + New: newHost, + }, + Origin: models.Dashboard, + }) apiHostData := newHost.ConvertNMHostToAPI() logger.Log(2, r.Header.Get("user"), "updated host", newHost.ID.String()) w.WriteHeader(http.StatusOK) @@ -420,7 +438,21 @@ func deleteHost(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } - + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: currHost.ID.String(), + Name: currHost.Name, + Type: models.DeviceSub, + }, + Origin: models.Dashboard, + }) apiHostData := currHost.ConvertNMHostToAPI() logger.Log(2, r.Header.Get("user"), "removed host", currHost.Name) w.WriteHeader(http.StatusOK) @@ -492,6 +524,22 @@ func addHostToNetwork(w http.ResponseWriter, r *http.Request) { r.Header.Get("user"), fmt.Sprintf("added host %s to network %s", currHost.Name, network), ) + logic.LogEvent(&models.Event{ + Action: models.JoinHostToNet, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: currHost.ID.String(), + Name: currHost.Name, + Type: models.DeviceSub, + }, + NetworkID: models.NetworkID(network), + Origin: models.Dashboard, + }) w.WriteHeader(http.StatusOK) } @@ -623,6 +671,22 @@ func deleteHostFromNetwork(w http.ResponseWriter, r *http.Request) { logic.SetDNS() } }() + logic.LogEvent(&models.Event{ + Action: models.RemoveHostFromNet, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: currHost.ID.String(), + Name: currHost.Name, + Type: models.DeviceSub, + }, + NetworkID: models.NetworkID(network), + Origin: models.Dashboard, + }) logger.Log( 2, r.Header.Get("user"), @@ -937,7 +1001,21 @@ func syncHost(w http.ResponseWriter, r *http.Request) { slog.Error("failed to send host pull request", "host", host.ID.String(), "error", err) } }() - + logic.LogEvent(&models.Event{ + Action: models.Sync, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: host.ID.String(), + Name: host.Name, + Type: models.DeviceSub, + }, + Origin: models.Dashboard, + }) slog.Info("requested host pull", "user", r.Header.Get("user"), "host", host.ID.String()) w.WriteHeader(http.StatusOK) } diff --git a/controllers/network.go b/controllers/network.go index 64109054..9b216a88 100644 --- a/controllers/network.go +++ b/controllers/network.go @@ -483,9 +483,9 @@ func deleteNetwork(w http.ResponseWriter, r *http.Request) { } err = logic.DeleteNetwork(network, force, doneCh) if err != nil { - errtype := "badrequest" + errtype := logic.BadReq if strings.Contains(err.Error(), "Node check failed") { - errtype = "forbidden" + errtype = logic.Forbidden } logger.Log(0, r.Header.Get("user"), fmt.Sprintf("failed to delete network [%s]: %v", network, err)) @@ -514,6 +514,21 @@ func deleteNetwork(w http.ResponseWriter, r *http.Request) { logic.SetDNS() } }() + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: network, + Name: network, + Type: models.NetworkSub, + }, + Origin: models.Dashboard, + }) logger.Log(1, r.Header.Get("user"), "deleted network", network) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode("success") @@ -636,7 +651,22 @@ func createNetwork(w http.ResponseWriter, r *http.Request) { logger.Log(1, "failed to publish peer update for default hosts after network is added") } }() - + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: network.NetID, + Name: network.NetID, + Type: models.NetworkSub, + Info: network, + }, + Origin: models.Dashboard, + }) logger.Log(1, r.Header.Get("user"), "created network", network.NetID) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(network) diff --git a/controllers/node.go b/controllers/node.go index 79e15bd4..0916ea32 100644 --- a/controllers/node.go +++ b/controllers/node.go @@ -178,7 +178,7 @@ func Authorize( // check if host instead of user if hostAllowed { // TODO --- should ensure that node is only operating on itself - if hostID, _, _, err := logic.VerifyHostToken(authToken); err == nil { + if hostID, macAddr, _, err := logic.VerifyHostToken(authToken); err == nil && macAddr != "" { r.Header.Set(hostIDHeader, hostID) // this indicates request is from a node // used for failover - if a getNode comes from node, this will trigger a metrics wipe @@ -650,7 +650,7 @@ func updateNode(w http.ResponseWriter, r *http.Request) { return } } - _, err = logic.GetHost(newNode.HostID.String()) + host, err := logic.GetHost(newNode.HostID.String()) if err != nil { logger.Log(0, r.Header.Get("user"), fmt.Sprintf("failed to get host for node [ %s ] info: %v", nodeid, err)) @@ -682,6 +682,25 @@ func updateNode(w http.ResponseWriter, r *http.Request) { "on network", currentNode.Network, ) + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: newNode.ID.String(), + Name: host.Name, + Type: models.NodeSub, + }, + Diff: models.Diff{ + Old: currentNode, + New: newNode, + }, + Origin: models.Dashboard, + }) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(apiNode) go func(aclUpdate, relayupdate bool, newNode *models.Node) { diff --git a/controllers/server.go b/controllers/server.go index 2b5f7583..bdcf51bd 100644 --- a/controllers/server.go +++ b/controllers/server.go @@ -271,6 +271,25 @@ func updateSettings(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to udpate server settings "+err.Error()), "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: models.SettingSub.String(), + Name: models.SettingSub.String(), + Type: models.SettingSub, + }, + Diff: models.Diff{ + Old: currSettings, + New: req, + }, + Origin: models.Dashboard, + }) go reInit(currSettings, req, force == "true") logic.ReturnSuccessResponseWithJson(w, r, req, "updated server settings successfully") } diff --git a/controllers/tags.go b/controllers/tags.go index fc803e0c..ddb5380c 100644 --- a/controllers/tags.go +++ b/controllers/tags.go @@ -131,6 +131,22 @@ func createTag(w http.ResponseWriter, r *http.Request) { logic.UpsertNode(&node) } }() + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: tag.ID.String(), + Name: tag.TagName, + Type: models.TagSub, + }, + NetworkID: tag.Network, + Origin: models.Dashboard, + }) go mq.PublishPeerUpdate(false) var res models.TagListRespNodes = models.TagListRespNodes{ @@ -163,6 +179,25 @@ func updateTag(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) return } + e := &models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: tag.ID.String(), + Name: tag.TagName, + Type: models.TagSub, + }, + Diff: models.Diff{ + Old: tag, + }, + NetworkID: tag.Network, + Origin: models.Dashboard, + } updateTag.NewName = strings.TrimSpace(updateTag.NewName) var newID models.TagID if updateTag.NewName != "" { @@ -198,7 +233,8 @@ func updateTag(w http.ResponseWriter, r *http.Request) { } mq.PublishPeerUpdate(false) }() - + e.Diff.New = updateTag + logic.LogEvent(e) var res models.TagListRespNodes = models.TagListRespNodes{ Tag: tag, UsedByCnt: len(updateTag.TaggedNodes), @@ -241,5 +277,21 @@ func deleteTag(w http.ResponseWriter, r *http.Request) { logic.RemoveTagFromEnrollmentKeys(tag.ID) mq.PublishPeerUpdate(false) }() + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: tag.ID.String(), + Name: tag.TagName, + Type: models.TagSub, + }, + NetworkID: tag.Network, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponse(w, r, "deleted tag "+tagID) } diff --git a/controllers/user.go b/controllers/user.go index 762b066f..386095b2 100644 --- a/controllers/user.go +++ b/controllers/user.go @@ -43,6 +43,7 @@ func userHandlers(r *mux.Router) { r.HandleFunc("/api/v1/users/access_token", logic.SecurityCheck(true, http.HandlerFunc(createUserAccessToken))).Methods(http.MethodPost) r.HandleFunc("/api/v1/users/access_token", logic.SecurityCheck(true, http.HandlerFunc(getUserAccessTokens))).Methods(http.MethodGet) r.HandleFunc("/api/v1/users/access_token", logic.SecurityCheck(true, http.HandlerFunc(deleteUserAccessTokens))).Methods(http.MethodDelete) + r.HandleFunc("/api/v1/users/logout", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(logout)))).Methods(http.MethodPost) } // @Summary Authenticate a user to retrieve an authorization token @@ -64,25 +65,25 @@ func createUserAccessToken(w http.ResponseWriter, r *http.Request) { if err != nil { logger.Log(0, "error decoding request body: ", err.Error()) - logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq)) return } if req.Name == "" { - logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("name is required"), "badrequest")) + logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("name is required"), logic.BadReq)) return } if req.UserName == "" { - logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username is required"), "badrequest")) + logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username is required"), logic.BadReq)) return } caller, err := logic.GetUser(r.Header.Get("user")) if err != nil { - logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized")) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.UnAuthorized)) return } user, err := logic.GetUser(req.UserName) if err != nil { - logic.ReturnErrorResponse(w, r, logic.FormatError(err, "unauthorized")) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.UnAuthorized)) return } if caller.UserName != user.UserName && caller.PlatformRoleID != models.SuperAdminRole { @@ -106,7 +107,7 @@ func createUserAccessToken(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse( w, r, - logic.FormatError(errors.New("error creating access token "+err.Error()), "internal"), + logic.FormatError(errors.New("error creating access token "+err.Error()), logic.Internal), ) return } @@ -115,10 +116,26 @@ func createUserAccessToken(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse( w, r, - logic.FormatError(errors.New("error creating access token "+err.Error()), "internal"), + logic.FormatError(errors.New("error creating access token "+err.Error()), logic.Internal), ) return } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: caller.UserName, + Name: caller.UserName, + Type: models.UserSub, + }, + TriggeredBy: caller.UserName, + Target: models.Subject{ + ID: req.ID, + Name: req.Name, + Type: models.UserAccessTokenSub, + Info: req, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponseWithJson(w, r, models.SuccessfulUserLoginResponse{ AuthToken: jwt, UserName: req.UserName, @@ -197,6 +214,22 @@ func deleteUserAccessTokens(w http.ResponseWriter, r *http.Request) { ) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: caller.UserName, + Name: caller.UserName, + Type: models.UserSub, + }, + TriggeredBy: caller.UserName, + Target: models.Subject{ + ID: a.ID, + Name: a.Name, + Type: models.UserAccessTokenSub, + Info: a, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponseWithJson(w, r, nil, "revoked access token") } @@ -258,6 +291,38 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) { logic.ReturnErrorResponse(response, request, logic.FormatError(errors.New("access denied to dashboard"), "unauthorized")) return } + // log user activity + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + }, + Origin: models.Dashboard, + }) + } else { + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.ClientAppSub.String(), + Name: models.ClientAppSub.String(), + Type: models.ClientAppSub, + }, + Origin: models.ClientApp, + }) } username := authRequest.UserName @@ -614,6 +679,21 @@ func createUser(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) return } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: caller.UserName, + Name: caller.UserName, + Type: models.UserSub, + }, + TriggeredBy: caller.UserName, + Target: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + Origin: models.Dashboard, + }) logic.DeleteUserInvite(user.UserName) logic.DeletePendingUser(user.UserName) go mq.PublishPeerUpdate(false) @@ -752,6 +832,25 @@ func updateUser(w http.ResponseWriter, r *http.Request) { if userchange.PlatformRoleID != user.PlatformRoleID || !logic.CompareMaps(user.UserGroups, userchange.UserGroups) { (&schema.UserAccessToken{UserName: user.UserName}).DeleteAllUserTokens(r.Context()) } + e := models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: caller.UserName, + Name: caller.UserName, + Type: models.UserSub, + }, + TriggeredBy: caller.UserName, + Target: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + Diff: models.Diff{ + Old: user, + New: userchange, + }, + Origin: models.Dashboard, + } user, err = logic.UpdateUser(&userchange, user) if err != nil { logger.Log(0, username, @@ -759,6 +858,7 @@ func updateUser(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) return } + logic.LogEvent(&e) go mq.PublishPeerUpdate(false) logger.Log(1, username, "was updated") json.NewEncoder(w).Encode(logic.ToReturnUser(*user)) @@ -837,6 +937,21 @@ func deleteUser(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: caller.UserName, + Name: caller.UserName, + Type: models.UserSub, + }, + TriggeredBy: caller.UserName, + Target: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + Origin: models.Dashboard, + }) // check and delete extclient with this ownerID go func() { extclients, err := logic.GetAllExtClients() @@ -902,3 +1017,50 @@ func listRoles(w http.ResponseWriter, r *http.Request) { logic.ReturnSuccessResponseWithJson(w, r, roles, "successfully fetched user roles permission templates") } + +// swagger:route POST /api/v1/user/logout user logout +// +// LogOut user. +// +// Schemes: https +// +// Security: +// oauth +// +// Responses: +// 200: userBodyResponse +func logout(w http.ResponseWriter, r *http.Request) { + // set header. + w.Header().Set("Content-Type", "application/json") + userName := r.URL.Query().Get("username") + user, err := logic.GetUser(userName) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq)) + return + } + var target models.SubjectType + if val := r.Header.Get("From-Ui"); val == "true" { + target = models.DashboardSub + } else { + target = models.ClientAppSub + } + if target != "" { + logic.LogEvent(&models.Event{ + Action: models.LogOut, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: target.String(), + Name: target.String(), + Type: target, + }, + Origin: models.Origin(target), + }) + } + + logic.ReturnSuccessResponse(w, r, "user logged out") +} diff --git a/db/db.go b/db/db.go index 0e1dc6ff..f0919fae 100644 --- a/db/db.go +++ b/db/db.go @@ -83,6 +83,18 @@ func FromContext(ctx context.Context) *gorm.DB { return db } +func SetPagination(ctx context.Context, page, pageSize int) context.Context { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + db := FromContext(ctx) + offset := (page - 1) * pageSize + return context.WithValue(ctx, dbCtxKey, db.Offset(offset).Limit(pageSize)) +} + // BeginTx returns a context with a new transaction. // If the context already has a db connection instance, // it uses that instance. Otherwise, it uses the diff --git a/go.mod b/go.mod index dadf0d56..3c1b664c 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index e4d312d0..f7823c29 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40= diff --git a/logic/errors.go b/logic/errors.go index 1f46e578..2931ed29 100644 --- a/logic/errors.go +++ b/logic/errors.go @@ -8,20 +8,30 @@ import ( "golang.org/x/exp/slog" ) +type ApiErrorType string + +const ( + Internal ApiErrorType = "internal" + BadReq ApiErrorType = "badrequest" + NotFound ApiErrorType = "notfound" + UnAuthorized ApiErrorType = "unauthorized" + Forbidden ApiErrorType = "forbidden" +) + // FormatError - takes ErrorResponse and uses correct code -func FormatError(err error, errType string) models.ErrorResponse { +func FormatError(err error, errType ApiErrorType) models.ErrorResponse { var status = http.StatusInternalServerError switch errType { - case "internal": + case Internal: status = http.StatusInternalServerError - case "badrequest": + case BadReq: status = http.StatusBadRequest - case "notfound": + case NotFound: status = http.StatusNotFound - case "unauthorized": + case UnAuthorized: status = http.StatusUnauthorized - case "forbidden": + case Forbidden: status = http.StatusForbidden default: status = http.StatusInternalServerError diff --git a/logic/extpeers.go b/logic/extpeers.go index 4dce7a7b..4ebe5554 100644 --- a/logic/extpeers.go +++ b/logic/extpeers.go @@ -125,6 +125,25 @@ func DeleteExtClient(network string, clientid string) error { } deleteExtClientFromCache(key) } + if extClient.RemoteAccessClientID != "" { + LogEvent(&models.Event{ + Action: models.Disconnect, + Source: models.Subject{ + ID: extClient.OwnerID, + Name: extClient.OwnerID, + Type: models.UserSub, + }, + TriggeredBy: extClient.OwnerID, + Target: models.Subject{ + ID: extClient.Network, + Name: extClient.Network, + Type: models.NetworkSub, + Info: extClient, + }, + NetworkID: models.NetworkID(extClient.Network), + Origin: models.ClientApp, + }) + } go RemoveNodeFromAclPolicy(extClient.ConvertToStaticNode()) return nil } diff --git a/logic/telemetry.go b/logic/telemetry.go index de9f9088..2e7f569a 100644 --- a/logic/telemetry.go +++ b/logic/telemetry.go @@ -20,6 +20,8 @@ var ( telServerRecord = models.Telemetry{} ) +var LogEvent = func(a *models.Event) {} + // posthog_pub_key - Key for sending data to PostHog const posthog_pub_key = "phc_1vEXhPOA1P7HP5jP2dVU9xDTUqXHAelmtravyZ1vvES" diff --git a/models/events.go b/models/events.go new file mode 100644 index 00000000..02b87a6b --- /dev/null +++ b/models/events.go @@ -0,0 +1,74 @@ +package models + +type Action string + +const ( + Create Action = "CREATE" + Update Action = "UPDATE" + Delete Action = "DELETE" + DeleteAll Action = "DELETE_ALL" + Login Action = "LOGIN" + LogOut Action = "LOGOUT" + Connect Action = "CONNECT" + Sync Action = "SYNC" + Disconnect Action = "DISCONNECT" + JoinHostToNet Action = "JOIN_HOST_TO_NETWORK" + RemoveHostFromNet Action = "REMOVE_HOST_FROM_NETWORK" +) + +type SubjectType string + +const ( + UserSub SubjectType = "USER" + UserAccessTokenSub SubjectType = "USER_ACCESS_TOKEN" + DeviceSub SubjectType = "DEVICE" + NodeSub SubjectType = "NODE" + GatewaySub SubjectType = "GATEWAY" + SettingSub SubjectType = "SETTING" + AclSub SubjectType = "ACL" + TagSub SubjectType = "TAG" + UserRoleSub SubjectType = "USER_ROLE" + UserGroupSub SubjectType = "USER_GROUP" + UserInviteSub SubjectType = "USER_INVITE" + PendingUserSub SubjectType = "PENDING_USER" + EgressSub SubjectType = "EGRESS" + NetworkSub SubjectType = "NETWORK" + DashboardSub SubjectType = "DASHBOARD" + EnrollmentKeySub SubjectType = "ENROLLMENT_KEY" + ClientAppSub SubjectType = "CLIENT-APP" +) + +func (sub SubjectType) String() string { + return string(sub) +} + +type Origin string + +const ( + Dashboard Origin = "DASHBOARD" + Api Origin = "API" + NMCTL Origin = "NMCTL" + ClientApp Origin = "CLIENT-APP" +) + +type Subject struct { + ID string `json:"id"` + Name string `json:"name"` + Type SubjectType `json:"subject_type"` + Info interface{} `json:"info"` +} + +type Diff struct { + Old interface{} + New interface{} +} + +type Event struct { + Action Action + Source Subject + Origin Origin + Target Subject + TriggeredBy string + NetworkID NetworkID + Diff Diff +} diff --git a/pro/auth/azure-ad.go b/pro/auth/azure-ad.go index dbcaae5a..e67edc3e 100644 --- a/pro/auth/azure-ad.go +++ b/pro/auth/azure-ad.go @@ -176,7 +176,22 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) { logger.Log(1, "could not parse jwt for user", authRequest.UserName) return } - + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + Info: user, + }, + Origin: models.Dashboard, + }) logger.Log(1, "completed azure OAuth sigin in for", content.Email) http.Redirect(w, r, servercfg.GetFrontendURL()+"/login?login="+jwt+"&user="+content.Email, http.StatusPermanentRedirect) } diff --git a/pro/auth/github.go b/pro/auth/github.go index 7641eae1..0d543f48 100644 --- a/pro/auth/github.go +++ b/pro/auth/github.go @@ -167,7 +167,22 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) { logger.Log(1, "could not parse jwt for user", authRequest.UserName) return } - + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + Info: user, + }, + Origin: models.Dashboard, + }) logger.Log(1, "completed github OAuth sigin in for", content.Email) http.Redirect(w, r, servercfg.GetFrontendURL()+"/login?login="+jwt+"&user="+content.Email, http.StatusPermanentRedirect) } diff --git a/pro/auth/google.go b/pro/auth/google.go index d64003ca..97bf3143 100644 --- a/pro/auth/google.go +++ b/pro/auth/google.go @@ -69,6 +69,7 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { handleOauthNotConfigured(w) return } + var inviteExists bool // check if invite exists for User in, err := logic.GetUserInvite(content.Email) @@ -160,6 +161,23 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) { return } + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + Info: user, + }, + Origin: models.Dashboard, + }) + logger.Log(1, "completed google OAuth sigin in for", content.Email) http.Redirect(w, r, fmt.Sprintf("%s/login?login=%s&user=%s", servercfg.GetFrontendURL(), jwt, content.Email), http.StatusPermanentRedirect) } diff --git a/pro/auth/oidc.go b/pro/auth/oidc.go index 3cfea511..37f8918f 100644 --- a/pro/auth/oidc.go +++ b/pro/auth/oidc.go @@ -167,7 +167,22 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) { logger.Log(1, "could not parse jwt for user", authRequest.UserName, jwtErr.Error()) return } - + logic.LogEvent(&models.Event{ + Action: models.Login, + Source: models.Subject{ + ID: user.UserName, + Name: user.UserName, + Type: models.UserSub, + }, + TriggeredBy: user.UserName, + Target: models.Subject{ + ID: models.DashboardSub.String(), + Name: models.DashboardSub.String(), + Type: models.DashboardSub, + Info: user, + }, + Origin: models.Dashboard, + }) logger.Log(1, "completed OIDC OAuth signin in for", content.Email) http.Redirect(w, r, servercfg.GetFrontendURL()+"/login?login="+jwt+"&user="+content.Email, http.StatusPermanentRedirect) } diff --git a/pro/controllers/events.go b/pro/controllers/events.go new file mode 100644 index 00000000..d2d066d5 --- /dev/null +++ b/pro/controllers/events.go @@ -0,0 +1,114 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/gravitl/netmaker/db" + "github.com/gravitl/netmaker/logic" + "github.com/gravitl/netmaker/models" + "github.com/gravitl/netmaker/schema" +) + +func EventHandlers(r *mux.Router) { + r.HandleFunc("/api/v1/network/activity", logic.SecurityCheck(true, http.HandlerFunc(listNetworkActivity))).Methods(http.MethodGet) + r.HandleFunc("/api/v1/user/activity", logic.SecurityCheck(true, http.HandlerFunc(listUserActivity))).Methods(http.MethodGet) + r.HandleFunc("/api/v1/activity", logic.SecurityCheck(true, http.HandlerFunc(listActivity))).Methods(http.MethodGet) +} + +// @Summary list activity. +// @Router /api/v1/activity [get] +// @Tags Activity +// @Param network_id query string true "network_id required to get the network events" +// @Success 200 {object} models.ReturnSuccessResponseWithJson +// @Failure 500 {object} models.ErrorResponse +func listNetworkActivity(w http.ResponseWriter, r *http.Request) { + netID := r.URL.Query().Get("network_id") + // Parse query parameters with defaults + if netID == "" { + logic.ReturnErrorResponse(w, r, models.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "network_id param is missing", + }) + return + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + ctx := db.WithContext(r.Context()) + netActivity, err := (&schema.Event{NetworkID: models.NetworkID(netID)}).ListByNetwork(db.SetPagination(ctx, page, pageSize)) + if err != nil { + logic.ReturnErrorResponse(w, r, models.ErrorResponse{ + Code: http.StatusInternalServerError, + Message: err.Error(), + }) + return + } + + logic.ReturnSuccessResponseWithJson(w, r, netActivity, "successfully fetched network activity") +} + +// @Summary list activity. +// @Router /api/v1/activity [get] +// @Tags Activity +// @Param network_id query string true "network_id required to get the network events" +// @Success 200 {object} models.ReturnSuccessResponseWithJson +// @Failure 500 {object} models.ErrorResponse +func listUserActivity(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + // Parse query parameters with defaults + if username == "" { + logic.ReturnErrorResponse(w, r, models.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "username param is missing", + }) + return + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + ctx := db.WithContext(r.Context()) + userActivity, err := (&schema.Event{TriggeredBy: username}).ListByUser(db.SetPagination(ctx, page, pageSize)) + if err != nil { + logic.ReturnErrorResponse(w, r, models.ErrorResponse{ + Code: http.StatusInternalServerError, + Message: err.Error(), + }) + return + } + + logic.ReturnSuccessResponseWithJson(w, r, userActivity, "successfully fetched user activity "+username) +} + +// @Summary list activity. +// @Router /api/v1/activity [get] +// @Tags Activity +// @Success 200 {object} models.ReturnSuccessResponseWithJson +// @Failure 500 {object} models.ErrorResponse +func listActivity(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + network := r.URL.Query().Get("network_id") + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + ctx := db.WithContext(r.Context()) + var err error + var events []schema.Event + e := &schema.Event{TriggeredBy: username, NetworkID: models.NetworkID(network)} + if username != "" && network != "" { + events, err = e.ListByUserAndNetwork(db.SetPagination(ctx, page, pageSize)) + } else if username != "" && network == "" { + events, err = e.ListByUser(db.SetPagination(ctx, page, pageSize)) + } else if username == "" && network != "" { + events, err = e.ListByNetwork(db.SetPagination(ctx, page, pageSize)) + } else { + events, err = e.List(db.SetPagination(ctx, page, pageSize)) + } + if err != nil { + logic.ReturnErrorResponse(w, r, models.ErrorResponse{ + Code: http.StatusInternalServerError, + Message: err.Error(), + }) + return + } + + logic.ReturnSuccessResponseWithJson(w, r, events, "successfully fetched all events ") +} diff --git a/pro/controllers/users.go b/pro/controllers/users.go index 91cf6579..94aade4c 100644 --- a/pro/controllers/users.go +++ b/pro/controllers/users.go @@ -62,7 +62,6 @@ func UserHandlers(r *mux.Router) { r.HandleFunc("/api/users/{username}/remote_access_gw/{remote_access_gateway_id}", logic.SecurityCheck(true, http.HandlerFunc(removeUserFromRemoteAccessGW))).Methods(http.MethodDelete) r.HandleFunc("/api/users/{username}/remote_access_gw", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUserRemoteAccessGwsV1)))).Methods(http.MethodGet) r.HandleFunc("/api/users/ingress/{ingress_id}", logic.SecurityCheck(true, http.HandlerFunc(ingressGatewayUsers))).Methods(http.MethodGet) - } // swagger:route POST /api/v1/users/invite-signup user userInviteSignUp @@ -248,6 +247,21 @@ func inviteUsers(w http.ResponseWriter, r *http.Request) { if err != nil { slog.Error("failed to insert invite for user", "email", invite.Email, "error", err) } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: callerUserName, + Name: callerUserName, + Type: models.UserSub, + }, + TriggeredBy: callerUserName, + Target: models.Subject{ + ID: inviteeEmail, + Name: inviteeEmail, + Type: models.UserInviteSub, + }, + Origin: models.Dashboard, + }) // notify user with magic link go func(invite models.UserInvite) { // Set E-Mail body. You can set plain text or html with text/html @@ -266,6 +280,7 @@ func inviteUsers(w http.ResponseWriter, r *http.Request) { } }(invite) } + logic.ReturnSuccessResponse(w, r, "triggered user invites") } @@ -309,6 +324,21 @@ func deleteUserInvite(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: email, + Name: email, + Type: models.UserInviteSub, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponse(w, r, "deleted user invite") } @@ -463,6 +493,21 @@ func createUserGroup(w http.ResponseWriter, r *http.Request) { user.UserGroups[userGroupReq.Group.ID] = struct{}{} logic.UpsertUser(*user) } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: userGroupReq.Group.ID.String(), + Name: userGroupReq.Group.Name, + Type: models.UserGroupSub, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponseWithJson(w, r, userGroupReq.Group, "created user group") } @@ -506,7 +551,25 @@ func updateUserGroup(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } - + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: userGroup.ID.String(), + Name: userGroup.Name, + Type: models.UserGroupSub, + }, + Diff: models.Diff{ + Old: currUserG, + New: userGroup, + }, + Origin: models.Dashboard, + }) // reset configs for service user go proLogic.UpdatesUserGwAccessOnGrpUpdates(currUserG.NetworkRoles, userGroup.NetworkRoles) logic.ReturnSuccessResponseWithJson(w, r, userGroup, "updated user group") @@ -551,6 +614,21 @@ func deleteUserGroup(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: userG.ID.String(), + Name: userG.Name, + Type: models.UserGroupSub, + }, + Origin: models.Dashboard, + }) go proLogic.UpdatesUserGwAccessOnGrpUpdates(userG.NetworkRoles, make(map[models.NetworkID]map[models.UserRoleID]struct{})) logic.ReturnSuccessResponseWithJson(w, r, nil, "deleted user group") } @@ -631,6 +709,21 @@ func createRole(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: userRole.ID.String(), + Name: userRole.Name, + Type: models.UserRoleSub, + }, + Origin: models.ClientApp, + }) logic.ReturnSuccessResponseWithJson(w, r, userRole, "created user role") } @@ -665,6 +758,25 @@ func updateRole(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Update, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: userRole.ID.String(), + Name: userRole.Name, + Type: models.UserRoleSub, + }, + Diff: models.Diff{ + Old: currRole, + New: userRole, + }, + Origin: models.Dashboard, + }) // reset configs for service user go proLogic.UpdatesUserGwAccessOnRoleUpdates(currRole.NetworkLevelAccess, userRole.NetworkLevelAccess, string(userRole.NetworkID)) logic.ReturnSuccessResponseWithJson(w, r, userRole, "updated user role") @@ -693,6 +805,21 @@ func deleteRole(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: role.ID.String(), + Name: role.Name, + Type: models.UserRoleSub, + }, + Origin: models.Dashboard, + }) go proLogic.UpdatesUserGwAccessOnRoleUpdates(role.NetworkLevelAccess, make(map[models.RsrcType]map[models.RsrcID]models.RsrcPermissionScope), role.NetworkID.String()) logic.ReturnSuccessResponseWithJson(w, r, nil, "deleted user role") } @@ -1349,6 +1476,21 @@ func approvePendingUser(w http.ResponseWriter, r *http.Request) { break } } + logic.LogEvent(&models.Event{ + Action: models.Create, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: username, + Name: username, + Type: models.PendingUserSub, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponse(w, r, "approved "+username) } @@ -1380,6 +1522,21 @@ func deletePendingUser(w http.ResponseWriter, r *http.Request) { break } } + logic.LogEvent(&models.Event{ + Action: models.Delete, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: username, + Name: username, + Type: models.PendingUserSub, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponse(w, r, "deleted pending "+username) } @@ -1395,5 +1552,20 @@ func deleteAllPendingUsers(w http.ResponseWriter, r *http.Request) { logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to delete all pending users "+err.Error()), "internal")) return } + logic.LogEvent(&models.Event{ + Action: models.DeleteAll, + Source: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.UserSub, + }, + TriggeredBy: r.Header.Get("user"), + Target: models.Subject{ + ID: r.Header.Get("user"), + Name: r.Header.Get("user"), + Type: models.PendingUserSub, + }, + Origin: models.Dashboard, + }) logic.ReturnSuccessResponse(w, r, "cleared all pending users") } diff --git a/pro/initialize.go b/pro/initialize.go index fc57aa99..67705a3f 100644 --- a/pro/initialize.go +++ b/pro/initialize.go @@ -34,6 +34,7 @@ func InitPro() { proControllers.FailOverHandlers, proControllers.InetHandlers, proControllers.RacHandlers, + proControllers.EventHandlers, ) controller.ListRoles = proControllers.ListRoles logic.EnterpriseCheckFuncs = append(logic.EnterpriseCheckFuncs, func() { @@ -93,6 +94,7 @@ func InitPro() { proLogic.LoadNodeMetricsToCache() proLogic.InitFailOverCache() email.Init() + proLogic.EventWatcher() }) logic.ResetFailOver = proLogic.ResetFailOver logic.ResetFailedOverPeer = proLogic.ResetFailedOverPeer @@ -140,6 +142,7 @@ func InitPro() { logic.GetNodeStatus = proLogic.GetNodeStatus logic.InitializeAuthProvider = auth.InitializeAuthProvider logic.EmailInit = email.Init + logic.LogEvent = proLogic.LogEvent } func retrieveProLogo() string { diff --git a/pro/logic/events.go b/pro/logic/events.go new file mode 100644 index 00000000..b8b1da15 --- /dev/null +++ b/pro/logic/events.go @@ -0,0 +1,47 @@ +package logic + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/gravitl/netmaker/db" + "github.com/gravitl/netmaker/models" + "github.com/gravitl/netmaker/schema" +) + +var EventActivityCh = make(chan models.Event, 100) + +func LogEvent(a *models.Event) { + EventActivityCh <- *a +} + +func EventWatcher() { + + for e := range EventActivityCh { + if e.Action == models.Update { + // check if diff + if cmp.Equal(e.Diff.Old, e.Diff.New) { + continue + } + } + sourceJson, _ := json.Marshal(e.Source) + dstJson, _ := json.Marshal(e.Target) + diff, _ := json.Marshal(e.Diff) + a := schema.Event{ + ID: uuid.New().String(), + Action: e.Action, + Source: sourceJson, + Target: dstJson, + Origin: e.Origin, + NetworkID: e.NetworkID, + TriggeredBy: e.TriggeredBy, + Diff: diff, + TimeStamp: time.Now().UTC(), + } + a.Create(db.WithContext(context.TODO())) + } + +} diff --git a/schema/event.go b/schema/event.go new file mode 100644 index 00000000..da08886c --- /dev/null +++ b/schema/event.go @@ -0,0 +1,55 @@ +package schema + +import ( + "context" + "time" + + "github.com/gravitl/netmaker/db" + "github.com/gravitl/netmaker/models" + "gorm.io/datatypes" +) + +type Event struct { + ID string `gorm:"primaryKey" json:"id"` + Action models.Action `gorm:"action" json:"action"` + Source datatypes.JSON `gorm:"source" json:"source"` + Origin models.Origin `gorm:"origin" json:"origin"` + Target datatypes.JSON `gorm:"target" json:"target"` + NetworkID models.NetworkID `gorm:"network_id" json:"network_id"` + TriggeredBy string `gorm:"triggered_by" json:"triggered_by"` + Diff datatypes.JSON `gorm:"diff" json:"diff"` + TimeStamp time.Time `gorm:"time_stamp" json:"time_stamp"` +} + +func (a *Event) Get(ctx context.Context) error { + return db.FromContext(ctx).Model(&Event{}).First(&a).Where("id = ?", a.ID).Error +} + +func (a *Event) Update(ctx context.Context) error { + return db.FromContext(ctx).Model(&Event{}).Where("id = ?", a.ID).Updates(&a).Error +} + +func (a *Event) Create(ctx context.Context) error { + return db.FromContext(ctx).Model(&Event{}).Create(&a).Error +} + +func (a *Event) ListByNetwork(ctx context.Context) (ats []Event, err error) { + err = db.FromContext(ctx).Model(&Event{}).Where("network_id = ?", a.NetworkID).Order("time_stamp DESC").Find(&ats).Error + return +} + +func (a *Event) ListByUser(ctx context.Context) (ats []Event, err error) { + err = db.FromContext(ctx).Model(&Event{}).Where("triggered_by = ?", a.TriggeredBy).Order("time_stamp DESC").Find(&ats).Error + return +} + +func (a *Event) ListByUserAndNetwork(ctx context.Context) (ats []Event, err error) { + err = db.FromContext(ctx).Model(&Event{}).Where("network_id = ? AND triggered_by = ?", + a.NetworkID, a.TriggeredBy).Order("time_stamp DESC").Find(&ats).Error + return +} + +func (a *Event) List(ctx context.Context) (ats []Event, err error) { + err = db.FromContext(ctx).Model(&Event{}).Order("time_stamp DESC").Find(&ats).Error + return +} diff --git a/schema/models.go b/schema/models.go index 1ffbd0e3..2f30d1f2 100644 --- a/schema/models.go +++ b/schema/models.go @@ -6,5 +6,6 @@ func ListModels() []interface{} { &Job{}, &Egress{}, &UserAccessToken{}, + &Event{}, } }