feat: activity log backend foundation
This commit is contained in:
1 parent
8907bb49fe
commit
50b1649965
74 files changed
+5949
-239
No files matched your search
@@ -0,0 +1,50 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
)
|
||||
|
||||
type GetActivityRetentionUseCase struct {
|
||||
settings audit.SettingsRepository
|
||||
}
|
||||
|
||||
func NewGetActivityRetentionUseCase(settings audit.SettingsRepository) *GetActivityRetentionUseCase {
|
||||
return &GetActivityRetentionUseCase{settings}
|
||||
}
|
||||
|
||||
func (g *GetActivityRetentionUseCase) GetActivityRetention() (audit.Settings, error) {
|
||||
return g.settings.Get()
|
||||
}
|
||||
|
||||
type UpdateActivityRetentionUseCase struct {
|
||||
settings audit.SettingsRepository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewUpdateActivityRetentionUseCase(settings audit.SettingsRepository, recorder audit.Recorder) *UpdateActivityRetentionUseCase {
|
||||
return &UpdateActivityRetentionUseCase{settings, recorder}
|
||||
}
|
||||
|
||||
func (u *UpdateActivityRetentionUseCase) UpdateActivityRetention(ctx context.Context, days int) (audit.Settings, error) {
|
||||
if err := audit.ValidateRetentionDays(days); err != nil {
|
||||
return audit.Settings{}, err
|
||||
}
|
||||
current, err := u.settings.Get()
|
||||
if err != nil {
|
||||
return audit.Settings{}, err
|
||||
}
|
||||
if current.RetentionDays == days {
|
||||
return current, nil
|
||||
}
|
||||
if err := u.settings.SaveRetention(days); err != nil {
|
||||
return audit.Settings{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionRetentionChanged, audit.CategorySystem)
|
||||
event.Params = map[string]any{audit.ParamFrom: current.RetentionDays, audit.ParamTo: days}
|
||||
u.recorder.Record(ctx, event)
|
||||
|
||||
current.RetentionDays = days
|
||||
return current, nil
|
||||
}
|
||||
@@ -1,20 +1,37 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type AuthenticateUserUseCase struct {
|
||||
userRepository user.Repository
|
||||
hasher hash.Hasher
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher) *AuthenticateUserUseCase {
|
||||
return &AuthenticateUserUseCase{userRepository, hasher}
|
||||
func NewAuthenticateUserUseCase(userRepository user.Repository, hasher hash.Hasher, recorder audit.Recorder) *AuthenticateUserUseCase {
|
||||
return &AuthenticateUserUseCase{userRepository, hasher, recorder}
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) Authenticate(username string, password string) (user.User, error) {
|
||||
func (a *AuthenticateUserUseCase) Authenticate(ctx context.Context, username string, password string) (user.User, error) {
|
||||
dbUser, err := a.verifyCredentials(username, password)
|
||||
if err != nil {
|
||||
a.recordRejectedLogin(ctx, username, err)
|
||||
return user.User{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionLogin, audit.CategoryAuth)
|
||||
event.Actor = audit.UserActor(dbUser)
|
||||
a.recorder.Record(ctx, event)
|
||||
return dbUser, nil
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) verifyCredentials(username string, password string) (user.User, error) {
|
||||
dbUser, err := a.userRepository.GetByUsername(username)
|
||||
if err != nil {
|
||||
return user.User{}, err
|
||||
@@ -26,3 +43,24 @@ func (a *AuthenticateUserUseCase) Authenticate(username string, password string)
|
||||
|
||||
return dbUser, nil
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) recordRejectedLogin(ctx context.Context, username string, cause error) {
|
||||
if !errors.Is(cause, user.ErrUserNotFound) && !errors.Is(cause, user.ErrInvalidCredentials) {
|
||||
return
|
||||
}
|
||||
reason := audit.ReasonWrongLogin
|
||||
event := audit.NewEvent(audit.ActionLoginFailed, audit.CategoryAuth)
|
||||
event.Actor = audit.AnonymousActor(username)
|
||||
event.Outcome = audit.OutcomeFailed
|
||||
event.FailureReason = &reason
|
||||
event.Security = true
|
||||
a.recorder.Record(ctx, event)
|
||||
}
|
||||
|
||||
func (a *AuthenticateUserUseCase) Logout(ctx context.Context) {
|
||||
origin, ok := audit.OriginFrom(ctx)
|
||||
if !ok || origin.Actor.Type != audit.ActorUser {
|
||||
return
|
||||
}
|
||||
a.recorder.Record(ctx, audit.NewEvent(audit.ActionLogout, audit.CategoryAuth))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CleanupActivityUseCase struct {
|
||||
events audit.Repository
|
||||
settings audit.SettingsRepository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewCleanupActivityUseCase(events audit.Repository, settings audit.SettingsRepository, recorder audit.Recorder) *CleanupActivityUseCase {
|
||||
return &CleanupActivityUseCase{events, settings, recorder}
|
||||
}
|
||||
|
||||
func (c *CleanupActivityUseCase) Tick(now time.Time) (audit.CleanupResult, bool, error) {
|
||||
settings, err := c.settings.Get()
|
||||
if err != nil {
|
||||
return audit.CleanupResult{}, false, err
|
||||
}
|
||||
if settings.RetentionDays == audit.ForeverRetentionDays || !settings.CleanupDue(now) {
|
||||
return audit.CleanupResult{}, false, nil
|
||||
}
|
||||
|
||||
removed, err := c.events.DeleteOlderThan(audit.RetentionCutoff(now, settings.RetentionDays))
|
||||
if err != nil {
|
||||
return audit.CleanupResult{}, false, err
|
||||
}
|
||||
if err := c.settings.SaveLastCleanup(now, removed); err != nil {
|
||||
return audit.CleanupResult{}, false, err
|
||||
}
|
||||
if removed > 0 {
|
||||
c.recordCleanup(removed)
|
||||
}
|
||||
return audit.CleanupResult{Removed: removed}, true, nil
|
||||
}
|
||||
|
||||
func (c *CleanupActivityUseCase) recordCleanup(removed int) {
|
||||
event := audit.NewEvent(audit.ActionCleanup, audit.CategorySystem)
|
||||
event.Actor = audit.SystemActor()
|
||||
event.Source = audit.SourceSystem
|
||||
event.Params = map[string]any{audit.ParamRemoved: removed}
|
||||
c.recorder.Record(context.Background(), event)
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -9,6 +11,7 @@ type CreateAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
keyGenerator apikey.KeyGenerator
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type CreateAPIKeyCommand struct {
|
||||
@@ -22,11 +25,11 @@ type CreatedAPIKey struct {
|
||||
RawKey string
|
||||
}
|
||||
|
||||
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time) *CreateAPIKeyUseCase {
|
||||
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now}
|
||||
func NewCreateAPIKeyUseCase(apiKeyRepository apikey.Repository, keyGenerator apikey.KeyGenerator, now func() time.Time, recorder audit.Recorder) *CreateAPIKeyUseCase {
|
||||
return &CreateAPIKeyUseCase{apiKeyRepository, keyGenerator, now, recorder}
|
||||
}
|
||||
|
||||
func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (CreatedAPIKey, error) {
|
||||
func (c *CreateAPIKeyUseCase) CreateAPIKey(ctx context.Context, command CreateAPIKeyCommand) (CreatedAPIKey, error) {
|
||||
now := c.now()
|
||||
if command.ExpiresAt != nil && !command.ExpiresAt.After(now) {
|
||||
return CreatedAPIKey{}, apikey.ErrExpiryNotInFuture
|
||||
@@ -45,5 +48,9 @@ func (c *CreateAPIKeyUseCase) CreateAPIKey(command CreateAPIKeyCommand) (Created
|
||||
return CreatedAPIKey{}, err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionAPIKeyCreate, audit.CategoryAPIKeys)
|
||||
event.Target = audit.APIKeyTarget(stored.ID, stored.Name)
|
||||
c.recorder.Record(ctx, event)
|
||||
|
||||
return CreatedAPIKey{Key: stored, RawKey: rawKey}, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const exportBatchSize = 500
|
||||
|
||||
const formulaGuard = "'"
|
||||
const formulaStarts = "=+-@"
|
||||
|
||||
var exportHeader = []string{"when", "actor", "action", "target", "outcome", "source", "ip"}
|
||||
|
||||
type ExportActivityUseCase struct {
|
||||
events audit.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ActivityExport struct {
|
||||
events audit.Repository
|
||||
filter audit.Filter
|
||||
}
|
||||
|
||||
func NewExportActivityUseCase(events audit.Repository, now func() time.Time) *ExportActivityUseCase {
|
||||
return &ExportActivityUseCase{events, now}
|
||||
}
|
||||
|
||||
func (e *ExportActivityUseCase) Prepare(query ActivityQuery) (*ActivityExport, error) {
|
||||
filter, err := buildActivityFilter(query, e.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ActivityExport{events: e.events, filter: filter}, nil
|
||||
}
|
||||
|
||||
func (a *ActivityExport) WriteCSV(w io.Writer) error {
|
||||
writer := csv.NewWriter(w)
|
||||
if err := writer.Write(exportHeader); err != nil {
|
||||
return err
|
||||
}
|
||||
for page := 1; ; page++ {
|
||||
filter := a.filter
|
||||
filter.Page, filter.Size = page, exportBatchSize
|
||||
found, err := a.events.List(filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, event := range found.Items {
|
||||
if err := writer.Write(exportRow(event)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(found.Items) < exportBatchSize || page*exportBatchSize >= found.Total {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exportRow(event audit.Event) []string {
|
||||
ip := ""
|
||||
if event.IP != nil {
|
||||
ip = *event.IP
|
||||
}
|
||||
return []string{
|
||||
event.CreatedAt.UTC().Format(time.RFC3339),
|
||||
guardCell(event.Actor.Name),
|
||||
guardCell(event.Action),
|
||||
guardCell(event.Target.Name),
|
||||
string(event.Outcome),
|
||||
string(event.Source),
|
||||
guardCell(ip),
|
||||
}
|
||||
}
|
||||
|
||||
func guardCell(value string) string {
|
||||
if value != "" && strings.ContainsRune(formulaStarts, rune(value[0])) {
|
||||
return formulaGuard + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GetActivitySummaryUseCase struct {
|
||||
events audit.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewGetActivitySummaryUseCase(events audit.Repository, now func() time.Time) *GetActivitySummaryUseCase {
|
||||
return &GetActivitySummaryUseCase{events, now}
|
||||
}
|
||||
|
||||
func (g *GetActivitySummaryUseCase) GetActivitySummary(query ActivityQuery) (audit.Summary, error) {
|
||||
window, err := resolveActivityWindow(query, g.now())
|
||||
if err != nil {
|
||||
return audit.Summary{}, err
|
||||
}
|
||||
return g.events.Summary(audit.SummaryQuery{From: window.from, To: window.to})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
targetArticle = "article"
|
||||
targetImage = "image"
|
||||
)
|
||||
|
||||
type ListActivityUseCase struct {
|
||||
events audit.Repository
|
||||
users user.Repository
|
||||
articles article.Repository
|
||||
images image.Repository
|
||||
apiKeys apikey.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ListActivityCommand struct {
|
||||
ViewerID uint32
|
||||
Query ActivityQuery
|
||||
}
|
||||
|
||||
type ActivityEntry struct {
|
||||
Event audit.Event
|
||||
TargetExists bool
|
||||
}
|
||||
|
||||
type ActivityPage struct {
|
||||
Items []ActivityEntry
|
||||
Total int
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
func NewListActivityUseCase(events audit.Repository, users user.Repository, articles article.Repository, images image.Repository, apiKeys apikey.Repository, now func() time.Time) *ListActivityUseCase {
|
||||
return &ListActivityUseCase{events, users, articles, images, apiKeys, now}
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) ListActivity(command ListActivityCommand) (ActivityPage, error) {
|
||||
filter, err := buildActivityFilter(command.Query, l.now())
|
||||
if err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
if filter.Page, filter.Size, err = resolveActivityPaging(command.Query); err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
if err := l.scopeToViewer(&filter, command.ViewerID); err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
|
||||
found, err := l.events.List(filter)
|
||||
if err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
entries, err := l.withTargetState(found.Items)
|
||||
if err != nil {
|
||||
return ActivityPage{}, err
|
||||
}
|
||||
return ActivityPage{Items: entries, Total: found.Total, Page: filter.Page, Size: filter.Size}, nil
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) scopeToViewer(filter *audit.Filter, viewerID uint32) error {
|
||||
viewer, err := l.users.Get(viewerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if viewer.Role == user.RoleOwner || viewer.Role == user.RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
own := audit.ActorRef{Type: audit.ActorUser, ID: &viewer.ID}
|
||||
if filter.Actor != nil && !sameActor(*filter.Actor, own) {
|
||||
return audit.ErrActorNotAllowed
|
||||
}
|
||||
filter.Actor = &own
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameActor(a audit.ActorRef, b audit.ActorRef) bool {
|
||||
return a.Type == b.Type && a.ID != nil && b.ID != nil && *a.ID == *b.ID
|
||||
}
|
||||
|
||||
type targetKey struct {
|
||||
kind string
|
||||
id string
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) withTargetState(events []audit.Event) ([]ActivityEntry, error) {
|
||||
known := map[targetKey]bool{}
|
||||
entries := make([]ActivityEntry, 0, len(events))
|
||||
for _, event := range events {
|
||||
key := targetKey{event.Target.Type, event.Target.ID}
|
||||
exists, resolved := known[key]
|
||||
if !resolved {
|
||||
var err error
|
||||
if exists, err = l.targetExists(event.Target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known[key] = exists
|
||||
}
|
||||
entries = append(entries, ActivityEntry{Event: event, TargetExists: exists})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (l *ListActivityUseCase) targetExists(target audit.Target) (bool, error) {
|
||||
id, err := strconv.ParseUint(target.ID, 10, 32)
|
||||
if err != nil {
|
||||
return true, nil
|
||||
}
|
||||
switch target.Type {
|
||||
case targetArticle:
|
||||
_, err = l.articles.Get(uint32(id))
|
||||
return existsUnless(err, article.ErrArticleNotFound)
|
||||
case audit.TargetUser:
|
||||
_, err = l.users.Get(uint32(id))
|
||||
return existsUnless(err, user.ErrUserNotFound)
|
||||
case audit.TargetAPIKey:
|
||||
_, err = l.apiKeys.Get(uint32(id))
|
||||
return existsUnless(err, apikey.ErrAPIKeyNotFound)
|
||||
case targetImage:
|
||||
_, err = l.images.Get(uint32(id))
|
||||
return existsUnless(err, image.ErrImageNotFound)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func existsUnless(err error, notFound error) (bool, error) {
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, notFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,7 @@ type RequestPasswordResetUseCase struct {
|
||||
hasher hash.Hasher
|
||||
mailRepository mail.Repository
|
||||
publicURL string
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type MailPasswordResetData struct {
|
||||
@@ -23,11 +26,11 @@ type MailPasswordResetData struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
func NewRequestPasswordResetUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *RequestPasswordResetUseCase {
|
||||
return &RequestPasswordResetUseCase{userRepository, hasher, mailRepository, publicURL}
|
||||
func NewRequestPasswordResetUseCase(userRepository user.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string, recorder audit.Recorder) *RequestPasswordResetUseCase {
|
||||
return &RequestPasswordResetUseCase{userRepository, hasher, mailRepository, publicURL, recorder}
|
||||
}
|
||||
|
||||
func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
|
||||
func (g *RequestPasswordResetUseCase) RequestPasswordReset(ctx context.Context, email string) error {
|
||||
foundUser, err := g.userRepository.GetByEmail(email)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -46,6 +49,8 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
g.recordRequest(ctx, foundUser)
|
||||
|
||||
if err := g.mailRepository.Send(foundUser.Email, "password-reset", MailPasswordResetData{
|
||||
Host: g.publicURL,
|
||||
VerificationCode: rawCode,
|
||||
@@ -57,3 +62,10 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *RequestPasswordResetUseCase) recordRequest(ctx context.Context, requester user.User) {
|
||||
event := audit.NewEvent(audit.ActionPasswordResetRequest, audit.CategoryAuth)
|
||||
event.Actor = audit.UserActor(requester)
|
||||
event.Security = true
|
||||
g.recorder.Record(ctx, event)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -12,13 +14,14 @@ type ResetPasswordUseCase struct {
|
||||
hasher hash.Hasher
|
||||
apiKeys apikey.Repository
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now}
|
||||
func NewResetPasswordUseCase(userRepository user.Repository, hasher hash.Hasher, apiKeys apikey.Repository, now func() time.Time, recorder audit.Recorder) *ResetPasswordUseCase {
|
||||
return &ResetPasswordUseCase{userRepository, hasher, apiKeys, now, recorder}
|
||||
}
|
||||
|
||||
func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassword string) error {
|
||||
func (r *ResetPasswordUseCase) ResetPassword(ctx context.Context, email string, code string, newPassword string) error {
|
||||
dbUser, err := r.userRepository.GetByEmail(email)
|
||||
if err != nil {
|
||||
return user.ErrInvalidPasswordResetCode
|
||||
@@ -41,5 +44,13 @@ func (r *ResetPasswordUseCase) ResetPassword(email string, code string, newPassw
|
||||
return err
|
||||
}
|
||||
|
||||
return r.apiKeys.RevokeAllByUser(dbUser.ID, r.now())
|
||||
if err := r.apiKeys.RevokeAllByUser(dbUser.ID, r.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionPasswordResetDone, audit.CategoryAuth)
|
||||
event.Actor = audit.UserActor(dbUser)
|
||||
event.Security = true
|
||||
r.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/apikey"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -10,13 +12,14 @@ type RevokeAPIKeyUseCase struct {
|
||||
apiKeyRepository apikey.Repository
|
||||
userRepository user.Repository
|
||||
now func() time.Time
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time) *RevokeAPIKeyUseCase {
|
||||
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now}
|
||||
func NewRevokeAPIKeyUseCase(apiKeyRepository apikey.Repository, userRepository user.Repository, now func() time.Time, recorder audit.Recorder) *RevokeAPIKeyUseCase {
|
||||
return &RevokeAPIKeyUseCase{apiKeyRepository, userRepository, now, recorder}
|
||||
}
|
||||
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) error {
|
||||
func (r *RevokeAPIKeyUseCase) RevokeAPIKey(ctx context.Context, requesterId uint32, keyId uint32) error {
|
||||
key, err := r.apiKeyRepository.Get(keyId)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -35,5 +38,12 @@ func (r *RevokeAPIKeyUseCase) RevokeAPIKey(requesterId uint32, keyId uint32) err
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.apiKeyRepository.Revoke(key.ID, r.now())
|
||||
if err := r.apiKeyRepository.Revoke(key.ID, r.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := audit.NewEvent(audit.ActionAPIKeyRevoke, audit.CategoryAPIKeys)
|
||||
event.Target = audit.APIKeyTarget(key.ID, key.Name)
|
||||
r.recorder.Record(ctx, event)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
activityRangeToday = "today"
|
||||
activityRange7Days = "7d"
|
||||
activityRange30Days = "30d"
|
||||
activityRangeCustom = "custom"
|
||||
activityDateLayout = "2006-01-02"
|
||||
activityDefaultSize = 25
|
||||
outcomeAll = "all"
|
||||
securityOnly = "true"
|
||||
)
|
||||
|
||||
var activityPageSizes = []int{25, 50, 100}
|
||||
|
||||
type ActivityQuery struct {
|
||||
Q string
|
||||
Actor string
|
||||
Category string
|
||||
Outcome string
|
||||
Security string
|
||||
Range string
|
||||
From string
|
||||
To string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type activityWindow struct {
|
||||
from time.Time
|
||||
to time.Time
|
||||
explicit bool
|
||||
}
|
||||
|
||||
func resolveActivityWindow(query ActivityQuery, now time.Time) (activityWindow, error) {
|
||||
utc := now.UTC()
|
||||
end := utc.Add(time.Second)
|
||||
switch query.Range {
|
||||
case "", activityRange7Days:
|
||||
return activityWindow{from: end.AddDate(0, 0, -7), to: end}, nil
|
||||
case activityRange30Days:
|
||||
return activityWindow{from: end.AddDate(0, 0, -30), to: end}, nil
|
||||
case activityRangeToday:
|
||||
return activityWindow{from: time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC), to: end}, nil
|
||||
case activityRangeCustom:
|
||||
return resolveCustomWindow(query)
|
||||
default:
|
||||
return activityWindow{}, audit.ErrInvalidFilter
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCustomWindow(query ActivityQuery) (activityWindow, error) {
|
||||
from, err := time.Parse(activityDateLayout, query.From)
|
||||
if err != nil {
|
||||
return activityWindow{}, audit.ErrInvalidFilter
|
||||
}
|
||||
last, err := time.Parse(activityDateLayout, query.To)
|
||||
if err != nil || last.Before(from) {
|
||||
return activityWindow{}, audit.ErrInvalidFilter
|
||||
}
|
||||
return activityWindow{from: from, to: last.AddDate(0, 0, 1), explicit: true}, nil
|
||||
}
|
||||
|
||||
func buildActivityFilter(query ActivityQuery, now time.Time) (audit.Filter, error) {
|
||||
window, err := resolveActivityWindow(query, now)
|
||||
if err != nil {
|
||||
return audit.Filter{}, err
|
||||
}
|
||||
filter := audit.Filter{Query: query.Q, From: &window.from}
|
||||
if window.explicit {
|
||||
filter.To = &window.to
|
||||
}
|
||||
if filter.Outcome, err = parseActivityOutcome(query.Outcome); err != nil {
|
||||
return audit.Filter{}, err
|
||||
}
|
||||
if filter.Security, err = parseActivitySecurity(query.Security); err != nil {
|
||||
return audit.Filter{}, err
|
||||
}
|
||||
if query.Category != "" {
|
||||
if filter.Category, err = audit.ParseCategory(query.Category); err != nil {
|
||||
return audit.Filter{}, err
|
||||
}
|
||||
}
|
||||
if query.Actor != "" {
|
||||
actor, err := audit.ParseActor(query.Actor)
|
||||
if err != nil {
|
||||
return audit.Filter{}, err
|
||||
}
|
||||
filter.Actor = &actor
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func parseActivityOutcome(raw string) (audit.Outcome, error) {
|
||||
switch raw {
|
||||
case "", outcomeAll:
|
||||
return "", nil
|
||||
case string(audit.OutcomeSuccess):
|
||||
return audit.OutcomeSuccess, nil
|
||||
case string(audit.OutcomeFailed):
|
||||
return audit.OutcomeFailed, nil
|
||||
default:
|
||||
return "", audit.ErrInvalidFilter
|
||||
}
|
||||
}
|
||||
|
||||
func parseActivitySecurity(raw string) (bool, error) {
|
||||
switch raw {
|
||||
case "":
|
||||
return false, nil
|
||||
case securityOnly:
|
||||
return true, nil
|
||||
default:
|
||||
return false, audit.ErrInvalidFilter
|
||||
}
|
||||
}
|
||||
|
||||
func resolveActivityPaging(query ActivityQuery) (int, int, error) {
|
||||
page, size := query.Page, query.Size
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if size == 0 {
|
||||
size = activityDefaultSize
|
||||
}
|
||||
if page < 1 || !containsInt(activityPageSizes, size) {
|
||||
return 0, 0, audit.ErrInvalidFilter
|
||||
}
|
||||
return page, size, nil
|
||||
}
|
||||
|
||||
func containsInt(values []int, wanted int) bool {
|
||||
for _, value := range values {
|
||||
if value == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user