Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id 9CCCD37 for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 21 Feb 2025 23:04:23 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.219.181; helo=mail-yb1-f181.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=qELmnPGS Received: from mail-yb1-f181.google.com (mail-yb1-f181.google.com [209.85.219.181]) by mail.netlandish.com (Postfix) with ESMTP id 6784C1D6432 for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 21 Feb 2025 23:12:24 +0000 (UTC) Received: by mail-yb1-f181.google.com with SMTP id 3f1490d57ef6-e587cca1e47so2540400276.0 for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 21 Feb 2025 15:12:24 -0800 (PST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1740179543; x=1740784343; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:to :from:from:to:cc:subject:date:message-id:reply-to; bh=7mqgXTU++h6+NjNlbRSwiadcYWe8Z2O9whXD+39VLUE=; b=qELmnPGSv3dNT0xibVP7/Wzj/E7gszjlrvqxbaX8q+RFxy3Q+3bAgE/a1IofBvOAHT w3HpP/bn1rIGDi8F6+6HIuzauXe08XxOAetofO6OOcCWemqo92SRsbt5Uz4LIUDvwFvR NV2555S4BTYnioT5MhHtSY0+2Utqe885zPAlg= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1740179543; x=1740784343; h=content-transfer-encoding:mime-version:message-id:date:subject:to :from:x-gm-message-state:from:to:cc:subject:date:message-id:reply-to; bh=7mqgXTU++h6+NjNlbRSwiadcYWe8Z2O9whXD+39VLUE=; b=R7q1jLZQSp5kCgtVGkxmCKHRghjnZ2139l0cPAL1V1ULWHN0zXeYWFScOYp4UMn2IG emnDuNbSFMgoPxCz3fwfM8LJNgvq/JpYwPaz2uKRMhQRff7fb+hLnXBc7USPaRuwR7ks r7ezYSOQrUGBlM80Oo/1EaEVgG7gF26wBTTdGownFlarOao493boH/d1xB2Hy4J6VE4o 8M432vE0p8asV+p/162l/jBZMkQSPJOMrK0yq0geJJzkChYbWSX223C7pezY5JM1/kPM tQTrgiCgL3wHWBp4go1a24jCq4osZjXU9755548HOaxNmkB26iaS3812TES29FP+7ar9 JaBA== X-Gm-Message-State: AOJu0Yyq9s/Bzdnbpj7do1JDWIdS+z6bswYwtVFPvuCk1HhE0ZLeBDPf 3up8EhwIITjueFCENoX2SyfWu2TT0dJyLQYyA79vhI2mEpxwspyKNkByxJ7vVhQ3BhxChq9BKMt oWAc= X-Gm-Gg: ASbGncsySgTy19/QsNodq5uLsfbePn8RHu2LiWf5xdwbjjYRjWwxmd+ayc1A+bVMUp6 oIy2eD+eBSX8p/zysAH2QC2krBsWrEi2/i82b7mG8bJnlCU1qiZFv80fzEyp1R7IvfBwclXz5Xf DvFahETNvCDIGkXQ1begu2IDBfO+hd7pEt0Egg/aH2KSuO6j4Khas4V4rGMZBKn2gBydYOWl9oh Y0I1VGVwYpQJJ/8AvF2lkL4MQjuYLIjGTLVoTy7o4A0Gtk+fBmGKg1vP+arhOX6UBcvkjy6Ys8y 1/S8nL/t70/7LYV4JWv3Ij0USw== X-Google-Smtp-Source: AGHT+IER9x9oQJaQfy89zB1g/FDEtWzEBM1TJxW2q5t1WOL1uN76XICUdkAFXVZvqAacFZIp2+EROw== X-Received: by 2002:a05:6902:1546:b0:e58:835:a7b0 with SMTP id 3f1490d57ef6-e5e2467a886mr3712139276.37.1740179543169; Fri, 21 Feb 2025 15:12:23 -0800 (PST) Received: from localhost ([2803:2d60:1118:5ee:bd8:a3af:9c0f:81c0]) by smtp.gmail.com with ESMTPSA id 3f1490d57ef6-e5dae10b9c9sm4992661276.48.2025.02.21.15.12.22 for <~netlandish/links-dev@lists.code.netlandish.com> (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Fri, 21 Feb 2025 15:12:22 -0800 (PST) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Subject: [PATCH links] Adding audit logs for various data modification functions. Date: Fri, 21 Feb 2025 17:11:39 -0600 Message-ID: <20250221231218.16424-1-peter@netlandish.com> X-Mailer: git-send-email 2.47.2 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit --- Still pending some handlers to display audit logs but I wanted to ensure we get record keeping in place asap as more and more users register. accounts/userfetch.go | 65 +++++ api/graph/schema.graphqls | 6 +- api/graph/schema.resolvers.go | 459 +++++++++++++++++++++++++++++++--- models/audit_log.go | 65 +++++ models/models.go | 5 - 5 files changed, 554 insertions(+), 46 deletions(-) create mode 100644 models/audit_log.go diff --git a/accounts/userfetch.go b/accounts/userfetch.go index 6b8bba4..b509086 100644 --- a/accounts/userfetch.go +++ b/accounts/userfetch.go @@ -143,7 +143,20 @@ func (u *UserFetch) ProcessSuccessfulLogout(c echo.Context) error { // ProcessSuccessfulPasswordChange handle tasks after user is logged in func (u *UserFetch) ProcessSuccessfulPasswordChange(c echo.Context) error { + gctx := c.(*server.Context) lt := localizer.GetSessionLocalizer(c) + err := models.RecordAuditLog( + c.Request().Context(), + int(gctx.User.GetID()), + c.RealIP(), + models.LOG_ACCT_PASSWORD_CHANGE, + "Successfully changed password.", + nil, + ) + if err != nil { + return err + } + messages.Success(c, lt.Translate("You've successfully updated your password.")) return nil } @@ -151,6 +164,19 @@ func (u *UserFetch) ProcessSuccessfulPasswordChange(c echo.Context) error { // ProcessSuccessfulPasswordReset handle tasks after user is logged in func (u *UserFetch) ProcessSuccessfulPasswordReset(c echo.Context) error { lt := localizer.GetSessionLocalizer(c) + user := c.Get("user").(*models.User) + err := models.RecordAuditLog( + c.Request().Context(), + int(user.ID), + c.RealIP(), + models.LOG_ACCT_PASSWORD_RESET, + "Successfully reset password.", + nil, + ) + if err != nil { + return err + } + messages.Success(c, lt.Translate("You've successfully updated your password.")) return nil } @@ -158,6 +184,19 @@ func (u *UserFetch) ProcessSuccessfulPasswordReset(c echo.Context) error { // ProcessSuccessfulEmailUpdate handle tasks after user is logged in func (u *UserFetch) ProcessSuccessfulEmailUpdate(c echo.Context) error { lt := localizer.GetSessionLocalizer(c) + user := c.Get("user").(*models.User) + err := models.RecordAuditLog( + c.Request().Context(), + int(user.ID), + c.RealIP(), + models.LOG_ACCT_EMAIL_UPDATE, + "Successfully updated account email.", + nil, + ) + if err != nil { + return err + } + messages.Success(c, lt.Translate("You've successfully updated your email address.")) return nil } @@ -180,6 +219,19 @@ func (u *UserFetch) ProcessSuccessfulEmailConfirmation(c echo.Context) error { if err != nil { gctx.Server.Logger().Printf("Error queueing SendySubscribeTask: %v", err) } + + err = models.RecordAuditLog( + c.Request().Context(), + int(user.ID), + c.RealIP(), + models.LOG_ACCT_EMAIL_CONF, + "Successfully confirmed account email.", + nil, + ) + if err != nil { + return err + } + messages.Success(c, lt.Translate("You've successfully confirmed your email address.")) return nil } @@ -234,6 +286,19 @@ func (u *UserFetch) ProcessSuccessfulLogin(c echo.Context, user gobwebs.User) er links.SetUserSession(c, lUser) lt := localizer.GetSessionLocalizer(c) + + err := models.RecordAuditLog( + c.Request().Context(), + int(lUser.ID), + c.RealIP(), + models.LOG_ACCT_LOGIN, + "Successful account login.", + nil, + ) + if err != nil { + return err + } + messages.Success(c, lt.Translate("Successful login.")) return nil } diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 4612125..79f72eb 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -814,6 +814,9 @@ type Mutation { "Create a new organization" addOrganization(input: OrganizationInput!): Organization! @access(scope: ORGS, kind: RW) + "Update organization details" + updateOrganization(input: UpdateOrganizationInput): Organization! @access(scope: ORGS, kind: RW) + "Add/Edit/Delete organization link. Also used edit/delete notes" addLink(input: LinkInput): OrgLink! @access(scope: LINKS, kind: RW) updateLink(input: UpdateLinkInput): OrgLink! @access(scope: LINKS, kind: RW) @@ -834,9 +837,6 @@ type Mutation { "Update user profile" updateProfile(input: ProfileInput): User! @access(scope: PROFILE, kind: RW) - "Update organization details" - updateOrganization(input: UpdateOrganizationInput): Organization! @access(scope: ORGS, kind: RW) - "Manage custom domains for an organization" addDomain(input: DomainInput!): Domain! @access(scope: DOMAINS, kind: RW) deleteDomain(id: Int!): DeletePayload! @access(scope: DOMAINS, kind: RW) diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index d82d48b..bd8d6ed 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -39,7 +39,6 @@ import ( "golang.org/x/image/draw" "golang.org/x/net/idna" "netlandish.com/x/gobwebs" - auditlog "netlandish.com/x/gobwebs-auditlog" oauth2 "netlandish.com/x/gobwebs-oauth2" gaccounts "netlandish.com/x/gobwebs/accounts" gcore "netlandish.com/x/gobwebs/core" @@ -89,6 +88,7 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga return nil, valid.ErrAuthorization } user := tokenUser.User.(*models.User) + c := server.EchoForContext(ctx) lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user) lt := localizer.GetLocalizer(lang) @@ -182,6 +182,20 @@ func (r *mutationResolver) AddOrganization(ctx context.Context, input model.Orga return nil, err } + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_ORG_ADDED, + fmt.Sprintf("Added organization '%s' (%d)", org.Slug, org.ID), + mdata, + ) + if err != nil { + return nil, err + } + return org, nil } @@ -352,6 +366,21 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput) } } + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + userID, + c.RealIP(), + models.LOG_BOOKMARK_ADDED, + fmt.Sprintf("Added bookmark '%s'", OrgLink.Hash), + mdata, + ) + if err != nil { + return nil, err + } + return OrgLink, nil } @@ -545,6 +574,28 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi return nil, err } } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + ltype := models.LOG_BOOKMARK_UPDATED + ldet := "bookmark" + if orgLink.Type == models.NoteType { + ltype = models.LOG_NOTE_UPDATED + ldet = "note" + } + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + ltype, + fmt.Sprintf("Updated %s '%s'", ldet, orgLink.Hash), + mdata, + ) + if err != nil { + return nil, err + } + return orgLink, nil } @@ -580,28 +631,28 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model. } link := orgLinks[0] - // If the user is not the link creator, verify org write permissions - if link.UserID != int(user.ID) { - opts = &database.FilterOptions{ - Filter: sq.And{ - sq.Expr("o.id = ?", link.OrgID), - sq.Expr("o.is_active = true"), - }, - Limit: 1, - } + opts = &database.FilterOptions{ + Filter: sq.And{ + sq.Expr("o.id = ?", link.OrgID), + sq.Expr("o.is_active = true"), + }, + Limit: 1, + } - orgs, err := models.GetOrganizations(ctx, opts) - if err != nil { - return nil, err - } + orgs, err := models.GetOrganizations(ctx, opts) + if err != nil { + return nil, err + } - if len(orgs) == 0 { - validator.Error("%s", lt.Translate("This user is not allowed to perform this action")). - WithCode(valid.ErrNotFoundCode) - return nil, nil - } + if len(orgs) == 0 { + validator.Error("%s", lt.Translate("This user is not allowed to perform this action")). + WithCode(valid.ErrNotFoundCode) + return nil, nil + } + org := orgs[0] - org := orgs[0] + // If the user is not the link creator, verify org write permissions + if link.UserID != int(user.ID) { if !org.CanWrite(ctx, user) { validator.Error("%s", lt.Translate("This user is not allowed to perform this action")). WithCode(valid.ErrNotFoundCode) @@ -628,6 +679,28 @@ func (r *mutationResolver) DeleteLink(ctx context.Context, hash string) (*model. return nil, err } } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + ltype := models.LOG_BOOKMARK_DELETED + ldet := "bookmark" + if link.Type == models.NoteType { + ltype = models.LOG_NOTE_DELETED + ldet = "note" + } + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + ltype, + fmt.Sprintf("Deleted %s '%s'", ldet, link.Hash), + mdata, + ) + if err != nil { + return nil, err + } + deletePayload.Success = true deletePayload.ObjectID = deletedID return deletePayload, nil @@ -929,6 +1002,22 @@ func (r *mutationResolver) AddMember(ctx context.Context, input *model.MemberInp if err != nil { return nil, err } + + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["user_id"] = user.ID + err = models.RecordAuditLog( + ctx, + int(currentUser.ID), + c.RealIP(), + models.LOG_MEMBER_ADDED, + fmt.Sprintf("Added member %s", user.Email), + mdata, + ) + if err != nil { + return nil, err + } + addMemberPayload.Success = true return addMemberPayload, nil } @@ -977,8 +1066,9 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema return nil, nil } + email = strings.ToLower(email) opts := &database.FilterOptions{ - Filter: sq.Eq{"u.email": strings.ToLower(email)}, + Filter: sq.Eq{"u.email": email}, Limit: 1, } @@ -1015,6 +1105,22 @@ func (r *mutationResolver) DeleteMember(ctx context.Context, orgSlug string, ema return nil, err } } + + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["user_id"] = duser.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_MEMBER_REMOVED, + fmt.Sprintf("Removed member %s", email), + mdata, + ) + if err != nil { + return nil, err + } + addMemberPayload.Success = true addMemberPayload.Message = lt.Translate("The member was removed successfully") } @@ -1126,6 +1232,22 @@ func (r *mutationResolver) ConfirmMember(ctx context.Context, key string) (*mode if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_MEMBER_CONFIRMED, + fmt.Sprintf("Confirmed member %s", user.Email), + mdata, + ) + if err != nil { + return nil, err + } + addMemberPayload := &model.AddMemberPayload{ Success: true, Message: lt.Translate("The user was added successfully"), @@ -1585,6 +1707,21 @@ func (r *mutationResolver) UpdateProfile(ctx context.Context, input *model.Profi } } + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = personalOrg.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_PROFILE_UPDATED, + fmt.Sprintf("Updated profile"), + mdata, + ) + if err != nil { + return nil, err + } + return user, nil } @@ -1764,6 +1901,21 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input *model. return nil, err } + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_ORG_UPDATED, + fmt.Sprintf("Updated organization '%s' (%d)", org.Slug, org.ID), + mdata, + ) + if err != nil { + return nil, err + } + return org, nil } @@ -1901,6 +2053,23 @@ func (r *mutationResolver) AddDomain(ctx context.Context, input model.DomainInpu return nil, err } } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["domain_id"] = domain.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_DOMAIN_ADDED, + fmt.Sprintf("Added domain '%s'", domain.LookupName), + mdata, + ) + if err != nil { + return nil, err + } + return domain, nil } @@ -1969,6 +2138,22 @@ func (r *mutationResolver) DeleteDomain(ctx context.Context, id int) (*model.Del return nil, err } + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = domain.OrgID + mdata["domain_id"] = domain.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_DOMAIN_DELETED, + fmt.Sprintf("Deleted domain '%s'", domain.LookupName), + mdata, + ) + if err != nil { + return nil, err + } + deletePayload.Success = true deletePayload.ObjectID = fmt.Sprint(id) return deletePayload, nil @@ -2170,14 +2355,16 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh } } - alog := auditlog.New( + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, int(user.ID), c.RealIP(), models.LOG_SHORT_ADDED, - fmt.Sprintf("Added short link '%s'", linkShort.ShortCode), + fmt.Sprintf("Added short link '%s' (%d)", linkShort.ShortCode, linkShort.ID), + mdata, ) - alog.AddMetadata("org_id", org.ID) - err = alog.Store(ctx) if err != nil { return nil, err } @@ -2192,6 +2379,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd return nil, valid.ErrAuthorization } user := tokenUser.User.(*models.User) + c := server.EchoForContext(ctx) lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user) lt := localizer.GetLocalizer(lang) @@ -2362,6 +2550,20 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd } } + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_SHORT_UPDATED, + fmt.Sprintf("Updated short link '%s' (%d)", linkShort.ShortCode, linkShort.ID), + mdata, + ) + if err != nil { + return nil, err + } + return linkShort, nil } @@ -2372,6 +2574,7 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model. return nil, valid.ErrAuthorization } user := tokenUser.User.(*models.User) + c := server.EchoForContext(ctx) lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user) lt := localizer.GetLocalizer(lang) deletePayload := &model.DeletePayload{ @@ -2413,6 +2616,21 @@ func (r *mutationResolver) DeleteLinkShort(ctx context.Context, id int) (*model. if err != nil { return nil, err } + + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_SHORT_DELETED, + fmt.Sprintf("Deleted short link '%s' (%d)", link.ShortCode, link.ID), + mdata, + ) + if err != nil { + return nil, err + } + deletePayload.Success = true deletePayload.ObjectID = deletedID return deletePayload, nil @@ -2714,6 +2932,22 @@ func (r *mutationResolver) AddListing(ctx context.Context, input *model.AddListi } } + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["list_id"] = listing.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_LIST_ADDED, + fmt.Sprintf("Added link listing '%s' (%d)", listing.Slug, listing.ID), + mdata, + ) + if err != nil { + return nil, err + } + return listing, nil } @@ -2799,6 +3033,24 @@ func (r *mutationResolver) AddListingLink(ctx context.Context, input *model.AddL if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["list_id"] = listing.ID + mdata["list_link_id"] = listingLink.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_LIST_LINK_ADDED, + fmt.Sprintf("Added listing entry '%s' (%d)", listingLink.Title, listingLink.ID), + mdata, + ) + if err != nil { + return nil, err + } + return listingLink, nil } @@ -3126,6 +3378,23 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat return nil, err } } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["list_id"] = listing.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_LIST_UPDATED, + fmt.Sprintf("Updated link listing '%s' (%d)", listing.Slug, listing.ID), + mdata, + ) + if err != nil { + return nil, err + } + return listing, nil } @@ -3199,6 +3468,24 @@ func (r *mutationResolver) UpdateListingLink(ctx context.Context, input *model.U if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["list_id"] = listingLink.ListingID + mdata["list_link_id"] = listingLink.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_LIST_LINK_UPDATED, + fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID), + mdata, + ) + if err != nil { + return nil, err + } + return listingLink, nil } @@ -3251,6 +3538,23 @@ func (r *mutationResolver) DeleteListing(ctx context.Context, id int) (*model.De if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["list_id"] = listing.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_LIST_DELETED, + fmt.Sprintf("Deleted link listing '%s' (%d)", listing.Slug, listing.ID), + mdata, + ) + if err != nil { + return nil, err + } + deletePayload.Success = true deletePayload.ObjectID = deletedID return deletePayload, nil @@ -3305,6 +3609,24 @@ func (r *mutationResolver) DeleteListingLink(ctx context.Context, id int) (*mode if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["list_id"] = listingLink.ListingID + mdata["list_link_id"] = listingLink.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_LIST_LINK_DELETED, + fmt.Sprintf("Updated listing entry '%s' (%d)", listingLink.Title, listingLink.ID), + mdata, + ) + if err != nil { + return nil, err + } + deletePayload.Success = true deletePayload.ObjectID = deletedID return deletePayload, nil @@ -3580,6 +3902,23 @@ func (r *mutationResolver) AddQRCode(ctx context.Context, input model.AddQRCodeI return nil, err } } + + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["qrcode_id"] = qr.ID + mdata["qrcode_type"] = qr.CodeType + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_QRCODE_ADDED, + fmt.Sprintf("Added QR code '%s', type: %s (%d)", qr.Title, qr.CodeType, qr.ID), + mdata, + ) + if err != nil { + return nil, err + } + return qr, nil } @@ -3632,6 +3971,24 @@ func (r *mutationResolver) DeleteQRCode(ctx context.Context, id int) (*model.Del if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + mdata["qrcode_id"] = qrCode.ID + mdata["qrcode_type"] = qrCode.CodeType + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_QRCODE_DELETED, + fmt.Sprintf("Deleted QR code '%s', type: %s (%d)", qrCode.Title, qrCode.CodeType, qrCode.ID), + mdata, + ) + if err != nil { + return nil, err + } + deletePayload.Success = true deletePayload.ObjectID = deletedID return deletePayload, nil @@ -3677,21 +4034,31 @@ func (r *mutationResolver) Follow(ctx context.Context, orgSlug string) (*model.F return nil, err } - if len(follows) > 0 { - validator.Error("%s", lt.Translate("This user already follows this org")). - WithField("orgSlug"). - WithCode(valid.ErrValidationCode) - return nil, nil - } + if len(follows) == 0 { + follow := &models.Follower{ + UserID: int(user.ID), + OrgID: org.ID, + } - follow := &models.Follower{ - UserID: int(user.ID), - OrgID: org.ID, - } + err = follow.Store(ctx) + if err != nil { + return nil, err + } - err = follow.Store(ctx) - if err != nil { - return nil, err + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_ORG_FOLLOW, + fmt.Sprintf("Followed organization '%s' (%d)", org.Slug, org.ID), + mdata, + ) + if err != nil { + return nil, err + } } payload := &model.FollowPayload{ @@ -3754,6 +4121,22 @@ func (r *mutationResolver) Unfollow(ctx context.Context, orgSlug string) (*model if err != nil { return nil, err } + + c := server.EchoForContext(ctx) + mdata := make(map[string]any) + mdata["org_id"] = org.ID + err = models.RecordAuditLog( + ctx, + int(user.ID), + c.RealIP(), + models.LOG_ORG_UNFOLLOW, + fmt.Sprintf("Unfollowed organization '%s' (%d)", org.Slug, org.ID), + mdata, + ) + if err != nil { + return nil, err + } + payload := &model.FollowPayload{ Success: true, Message: lt.Translate("User unfollows %s", orgSlug), diff --git a/models/audit_log.go b/models/audit_log.go new file mode 100644 index 0000000..5e5f268 --- /dev/null +++ b/models/audit_log.go @@ -0,0 +1,65 @@ +package models + +import ( + "context" + + auditlog "netlandish.com/x/gobwebs-auditlog" +) + +// Event types for audit log entries +const ( + LOG_ACCT_LOGIN = "account_login" + LOG_ACCT_EMAIL_UPDATE = "account_email_update" + LOG_ACCT_EMAIL_CONF = "account_email_confirmation" + LOG_ACCT_PASSWORD_CHANGE = "account_password_change" + LOG_ACCT_PASSWORD_RESET = "account_password_reset" + + LOG_PROFILE_UPDATED = "profile_updated" + + LOG_ORG_ADDED = "organization_added" + LOG_ORG_UPDATED = "organization_updated" + + LOG_BOOKMARK_ADDED = "bookmark_added" + LOG_BOOKMARK_UPDATED = "bookmark_added" + LOG_BOOKMARK_DELETED = "bookmark_added" + + LOG_NOTE_ADDED = "note_added" + LOG_NOTE_UPDATED = "note_added" + LOG_NOTE_DELETED = "note_added" + + LOG_SHORT_ADDED = "short_added" + LOG_SHORT_UPDATED = "short_updated" + LOG_SHORT_DELETED = "short_deleted" + + LOG_LIST_ADDED = "list_added" + LOG_LIST_UPDATED = "list_updated" + LOG_LIST_DELETED = "list_deleted" + + LOG_LIST_LINK_ADDED = "list_link_added" + LOG_LIST_LINK_UPDATED = "list_link_updated" + LOG_LIST_LINK_DELETED = "list_link_deleted" + + LOG_MEMBER_ADDED = "member_added" + LOG_MEMBER_CONFIRMED = "member_confirmed" + LOG_MEMBER_REMOVED = "member_removed" + + LOG_DOMAIN_ADDED = "domain_added" + LOG_DOMAIN_DELETED = "domain_deleted" + + LOG_QRCODE_ADDED = "qrcode_added" + LOG_QRCODE_DELETED = "qrcode_deleted" + + LOG_ORG_FOLLOW = "org_follow" + LOG_ORG_UNFOLLOW = "org_unfollow" +) + +func RecordAuditLog(ctx context.Context, userID int, + ipAddress, eventType, details string, metadata map[string]any) error { + alog := auditlog.New(userID, ipAddress, eventType, details) + if metadata != nil { + for k, v := range metadata { + alog.AddMetadata(k, v) + } + } + return alog.Store(ctx) +} diff --git a/models/models.go b/models/models.go index 5dff73d..6728306 100644 --- a/models/models.go +++ b/models/models.go @@ -19,11 +19,6 @@ const ( PRIVATERSSFEEDCONF = 300 ) -// Event types for audit log entries -const ( - LOG_SHORT_ADDED = "short_added" -) - type AnalyticsData map[string]int // User ... -- 2.47.2