• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

goto / guardian / 18833345550

27 Oct 2025 07:33AM UTC coverage: 68.859% (-0.1%) from 68.976%
18833345550

Pull #233

github

anjaliagg9791
feat: support dry_run on create appeal
Pull Request #233: feat: support dry_run on create appeal

20 of 30 new or added lines in 2 files covered. (66.67%)

229 existing lines in 1 file now uncovered.

11403 of 16560 relevant lines covered (68.86%)

4.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.91
/core/appeal/service.go
1
package appeal
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "errors"
7
        "fmt"
8
        "io"
9
        "reflect"
10
        "strconv"
11
        "strings"
12
        "sync"
13
        "time"
14

15
        "github.com/go-playground/validator/v10"
16
        "github.com/goto/guardian/core/comment"
17
        "github.com/goto/guardian/core/event"
18
        "github.com/goto/guardian/core/grant"
19
        "github.com/goto/guardian/core/policy"
20
        "github.com/goto/guardian/domain"
21
        "github.com/goto/guardian/pkg/evaluator"
22
        "github.com/goto/guardian/pkg/http"
23
        "github.com/goto/guardian/pkg/log"
24
        "github.com/goto/guardian/plugins/notifiers"
25
        "github.com/goto/guardian/utils"
26
        "github.com/mitchellh/mapstructure"
27
        "golang.org/x/sync/errgroup"
28
)
29

30
const (
31
        AuditKeyBulkInsert     = "appeal.bulkInsert"
32
        AuditKeyUpdate         = "appeal.update"
33
        AuditKeyCancel         = "appeal.cancel"
34
        AuditKeyApprove        = "appeal.approve"
35
        AuditKeyReject         = "appeal.reject"
36
        AuditKeyRevoke         = "appeal.revoke"
37
        AuditKeyExtend         = "appeal.extend"
38
        AuditKeyAddApprover    = "appeal.addApprover"
39
        AuditKeyDeleteApprover = "appeal.deleteApprover"
40

41
        RevokeReasonForExtension = "Automatically revoked for grant extension"
42
        RevokeReasonForOverride  = "Automatically revoked for grant override"
43
)
44

45
var TimeNow = time.Now
46

47
//go:generate mockery --name=repository --exported --with-expecter
48
type repository interface {
49
        BulkUpsert(context.Context, []*domain.Appeal) error
50
        Find(context.Context, *domain.ListAppealsFilter) ([]*domain.Appeal, error)
51
        GetByID(ctx context.Context, id string) (*domain.Appeal, error)
52
        UpdateByID(context.Context, *domain.Appeal) error
53
        Update(context.Context, *domain.Appeal) error
54
        GetAppealsTotalCount(context.Context, *domain.ListAppealsFilter) (int64, error)
55
}
56

57
//go:generate mockery --name=iamManager --exported --with-expecter
58
type iamManager interface {
59
        domain.IAMManager
60
}
61

62
//go:generate mockery --name=notifier --exported --with-expecter
63
type notifier interface {
64
        notifiers.Client
65
}
66

67
//go:generate mockery --name=policyService --exported --with-expecter
68
type policyService interface {
69
        Find(context.Context) ([]*domain.Policy, error)
70
        GetOne(context.Context, string, uint) (*domain.Policy, error)
71
}
72

73
//go:generate mockery --name=approvalService --exported --with-expecter
74
type approvalService interface {
75
        AddApprover(ctx context.Context, approvalID, email string) error
76
        DeleteApprover(ctx context.Context, approvalID, email string) error
77
}
78

79
//go:generate mockery --name=providerService --exported --with-expecter
80
type providerService interface {
81
        Find(context.Context) ([]*domain.Provider, error)
82
        GrantAccess(context.Context, domain.Grant) error
83
        RevokeAccess(context.Context, domain.Grant) error
84
        ValidateAppeal(context.Context, *domain.Appeal, *domain.Provider, *domain.Policy) error
85
        GetPermissions(context.Context, *domain.ProviderConfig, string, string) ([]interface{}, error)
86
        IsExclusiveRoleAssignment(context.Context, string, string) bool
87
        GetDependencyGrants(context.Context, domain.Grant) ([]*domain.Grant, error)
88
}
89

90
//go:generate mockery --name=resourceService --exported --with-expecter
91
type resourceService interface {
92
        Find(context.Context, domain.ListResourcesFilter) ([]*domain.Resource, error)
93
        Get(context.Context, *domain.ResourceIdentifier) (*domain.Resource, error)
94
}
95

96
//go:generate mockery --name=grantService --exported --with-expecter
97
type grantService interface {
98
        List(context.Context, domain.ListGrantsFilter) ([]domain.Grant, error)
99
        Prepare(context.Context, domain.Appeal) (*domain.Grant, error)
100
        Revoke(ctx context.Context, id, actor, reason string, opts ...grant.Option) (*domain.Grant, error)
101
        Create(ctx context.Context, grant *domain.Grant) error
102
}
103

104
//go:generate mockery --name=auditLogger --exported --with-expecter
105
type auditLogger interface {
106
        Log(ctx context.Context, action string, data interface{}) error
107
}
108

109
type CreateAppealOption func(*createAppealOptions)
110

111
type createAppealOptions struct {
112
        IsAdditionalAppeal bool
113
        DryRun             bool
114
}
115

116
func CreateWithAdditionalAppeal() CreateAppealOption {
2✔
117
        return func(opts *createAppealOptions) {
6✔
118
                opts.IsAdditionalAppeal = true
4✔
119
        }
4✔
120
}
121

NEW
122
func CreateWithDryRun() CreateAppealOption {
×
NEW
123
        return func(opts *createAppealOptions) {
×
NEW
124
                opts.DryRun = true
×
NEW
125
        }
×
126
}
127

128
type ServiceDeps struct {
129
        Repository      repository
130
        ApprovalService approvalService
131
        ResourceService resourceService
132
        ProviderService providerService
133
        PolicyService   policyService
134
        GrantService    grantService
135
        CommentService  *comment.Service
136
        EventService    *event.Service
137
        IAMManager      iamManager
138

139
        Notifier    notifier
140
        Validator   *validator.Validate
141
        Logger      log.Logger
142
        AuditLogger auditLogger
143
}
144

145
// Service handling the business logics
146
type Service struct {
147
        repo            repository
148
        approvalService approvalService
149
        resourceService resourceService
150
        providerService providerService
151
        policyService   policyService
152
        grantService    grantService
153
        commentService  *comment.Service
154
        eventService    *event.Service
155
        iam             domain.IAMManager
156

157
        notifier    notifier
158
        validator   *validator.Validate
159
        logger      log.Logger
160
        auditLogger auditLogger
161

162
        TimeNow func() time.Time
163
}
164

165
// NewService returns service struct
166
func NewService(deps ServiceDeps) *Service {
106✔
167
        return &Service{
106✔
168
                deps.Repository,
106✔
169
                deps.ApprovalService,
106✔
170
                deps.ResourceService,
106✔
171
                deps.ProviderService,
106✔
172
                deps.PolicyService,
106✔
173
                deps.GrantService,
106✔
174
                deps.CommentService,
106✔
175
                deps.EventService,
106✔
176
                deps.IAMManager,
106✔
177

106✔
178
                deps.Notifier,
106✔
179
                deps.Validator,
106✔
180
                deps.Logger,
106✔
181
                deps.AuditLogger,
106✔
182
                time.Now,
106✔
183
        }
106✔
184
}
106✔
185

186
// GetByID returns one record by id
187
func (s *Service) GetByID(ctx context.Context, id string) (*domain.Appeal, error) {
55✔
188
        if id == "" {
56✔
189
                return nil, ErrAppealIDEmptyParam
1✔
190
        }
1✔
191

192
        if !utils.IsValidUUID(id) {
54✔
193
                return nil, InvalidError{AppealID: id}
×
194
        }
×
195

196
        return s.repo.GetByID(ctx, id)
54✔
197
}
198

199
// Find appeals by filters
200
func (s *Service) Find(ctx context.Context, filters *domain.ListAppealsFilter) ([]*domain.Appeal, error) {
2✔
201
        return s.repo.Find(ctx, filters)
2✔
202
}
2✔
203

204
// Create record
205
func (s *Service) Create(ctx context.Context, appeals []*domain.Appeal, opts ...CreateAppealOption) error {
32✔
206
        createAppealOpts := &createAppealOptions{}
32✔
207
        for _, opt := range opts {
34✔
208
                opt(createAppealOpts)
2✔
209
        }
2✔
210
        isAdditionalAppealCreation := createAppealOpts.IsAdditionalAppeal
32✔
211
        isDryRun := createAppealOpts.DryRun
32✔
212

32✔
213
        resourceIDs := []string{}
32✔
214
        accountIDs := []string{}
32✔
215
        for _, a := range appeals {
62✔
216
                resourceIDs = append(resourceIDs, a.ResourceID)
30✔
217
                accountIDs = append(accountIDs, a.AccountID)
30✔
218
        }
30✔
219

220
        eg, egctx := errgroup.WithContext(ctx)
32✔
221
        var (
32✔
222
                resources      map[string]*domain.Resource
32✔
223
                providers      map[string]map[string]*domain.Provider
32✔
224
                policies       map[string]map[uint]*domain.Policy
32✔
225
                pendingAppeals map[string]map[string]map[string]*domain.Appeal
32✔
226
        )
32✔
227

32✔
228
        eg.Go(func() error {
64✔
229
                resourcesData, err := s.getResourcesMap(egctx, resourceIDs)
32✔
230
                if err != nil {
33✔
231
                        return fmt.Errorf("error getting resource map: %w", err)
1✔
232
                }
1✔
233
                resources = resourcesData
31✔
234
                return nil
31✔
235
        })
236

237
        eg.Go(func() error {
64✔
238
                providersData, err := s.getProvidersMap(egctx)
32✔
239
                if err != nil {
33✔
240
                        return fmt.Errorf("error getting providers map: %w", err)
1✔
241
                }
1✔
242
                providers = providersData
31✔
243
                return nil
31✔
244
        })
245

246
        eg.Go(func() error {
64✔
247
                policiesData, err := s.getPoliciesMap(egctx)
32✔
248
                if err != nil {
33✔
249
                        return fmt.Errorf("error getting policies map: %w", err)
1✔
250
                }
1✔
251
                policies = policiesData
31✔
252
                return nil
31✔
253
        })
254

255
        eg.Go(func() error {
64✔
256
                pendingAppealsData, err := s.getAppealsMap(egctx, &domain.ListAppealsFilter{
32✔
257
                        Statuses:   []string{domain.AppealStatusPending},
32✔
258
                        AccountIDs: accountIDs,
32✔
259
                })
32✔
260
                if err != nil {
33✔
261
                        return fmt.Errorf("listing pending appeals: %w", err)
1✔
262
                }
1✔
263
                pendingAppeals = pendingAppealsData
31✔
264
                return nil
31✔
265
        })
266

267
        if err := eg.Wait(); err != nil {
36✔
268
                return err
4✔
269
        }
4✔
270

271
        notifications := []domain.Notification{}
28✔
272

28✔
273
        for _, appeal := range appeals {
58✔
274
                appeal.SetDefaults()
30✔
275

30✔
276
                if err := validateAppeal(appeal, pendingAppeals); err != nil {
31✔
277
                        return err
1✔
278
                }
1✔
279

280
                // Validate package RAM role usage
281
                if err := s.validatePackageRAMRoleUsage(ctx, appeal); err != nil {
29✔
UNCOV
282
                        return fmt.Errorf("package RAM role validation: %w", err)
×
UNCOV
283
                }
×
284

285
                if err := addResource(appeal, resources); err != nil {
30✔
286
                        return fmt.Errorf("couldn't find resource with id %q: %w", appeal.ResourceID, err)
1✔
287
                }
1✔
288
                provider, err := getProvider(appeal, providers)
28✔
289
                if err != nil {
30✔
290
                        return err
2✔
291
                }
2✔
292

293
                var policy *domain.Policy
26✔
294
                if isAdditionalAppealCreation && appeal.PolicyID != "" && appeal.PolicyVersion != 0 {
27✔
295
                        policy = policies[appeal.PolicyID][appeal.PolicyVersion]
1✔
296
                } else {
26✔
297
                        policy, err = getPolicy(appeal, provider, policies)
25✔
298
                        if err != nil {
28✔
299
                                return err
3✔
300
                        }
3✔
301
                }
302

303
                activeGrant, err := s.findActiveGrant(ctx, appeal)
23✔
304
                if err != nil && err != ErrGrantNotFound {
23✔
UNCOV
305
                        return err
×
UNCOV
306
                }
×
307

308
                if activeGrant != nil {
37✔
309
                        if err := s.checkExtensionEligibility(appeal, provider, policy, activeGrant); err != nil {
17✔
310
                                return err
3✔
311
                        }
3✔
312
                }
313

314
                if err := s.providerService.ValidateAppeal(ctx, appeal, provider, policy); err != nil {
24✔
315
                        return fmt.Errorf("provider validation: %w", err)
4✔
316
                }
4✔
317

318
                strPermissions, err := s.getPermissions(ctx, provider.Config, appeal.Resource.Type, appeal.Role)
16✔
319
                if err != nil {
16✔
UNCOV
320
                        return fmt.Errorf("getting permissions list: %w", err)
×
UNCOV
321
                }
×
322
                appeal.Permissions = strPermissions
16✔
323

16✔
324
                if err := validateAppealDurationConfig(appeal, policy); err != nil {
17✔
325
                        return err
1✔
326
                }
1✔
327

328
                if err := validateAppealOnBehalf(appeal, policy); err != nil {
16✔
329
                        return err
1✔
330
                }
1✔
331

332
                if err := s.addCreatorDetails(ctx, appeal, policy); err != nil {
14✔
UNCOV
333
                        return fmt.Errorf("getting creator details: %w", err)
×
UNCOV
334
                }
×
335

336
                if err := s.populateAppealMetadata(ctx, appeal, policy); err != nil {
15✔
337
                        return fmt.Errorf("getting appeal metadata: %w", err)
1✔
338
                }
1✔
339

340
                appeal.Revision = 0
13✔
341
                if err := appeal.ApplyPolicy(policy); err != nil {
13✔
342
                        return err
×
UNCOV
343
                }
×
344

345
                if err := appeal.AdvanceApproval(policy); err != nil {
13✔
UNCOV
346
                        return fmt.Errorf("initializing approvals: %w", err)
×
UNCOV
347
                }
×
348
                appeal.Policy = nil
13✔
349

13✔
350
                for _, approval := range appeal.Approvals {
36✔
351
                        // TODO: direcly check on appeal.Status==domain.AppealStatusApproved instead of manual looping through approvals
23✔
352
                        if approval.Index == len(appeal.Approvals)-1 && (approval.Status == domain.ApprovalStatusApproved || appeal.Status == domain.AppealStatusApproved) {
27✔
353
                                newGrant, prevGrant, err := s.prepareGrant(ctx, appeal)
4✔
354
                                if err != nil {
4✔
NEW
355
                                        return fmt.Errorf("preparing grant: %w", err)
×
NEW
356
                                }
×
357
                                newGrant.Resource = appeal.Resource
4✔
358
                                appeal.Grant = newGrant
4✔
359

4✔
360
                                // Skip grant operations in dry run mode
4✔
361
                                if !isDryRun {
8✔
362
                                        if prevGrant != nil {
5✔
363
                                                if _, err := s.grantService.Revoke(ctx, prevGrant.ID, domain.SystemActorName, prevGrant.RevokeReason,
1✔
364
                                                        grant.SkipNotifications(),
1✔
365
                                                        grant.SkipRevokeAccessInProvider(),
1✔
366
                                                ); err != nil {
1✔
NEW
367
                                                        return fmt.Errorf("revoking previous grant: %w", err)
×
NEW
368
                                                }
×
369
                                        }
370

371
                                        if err := s.GrantAccessToProvider(ctx, appeal, opts...); err != nil {
4✔
UNCOV
372
                                                return fmt.Errorf("granting access: %w", err)
×
UNCOV
373
                                        }
×
374
                                }
375

376
                                notifications = append(notifications, domain.Notification{
4✔
377
                                        User: appeal.CreatedBy,
4✔
378
                                        Labels: map[string]string{
4✔
379
                                                "appeal_id": appeal.ID,
4✔
380
                                        },
4✔
381
                                        Message: domain.NotificationMessage{
4✔
382
                                                Type: domain.NotificationTypeAppealApproved,
4✔
383
                                                Variables: map[string]interface{}{
4✔
384
                                                        "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
4✔
385
                                                        "role":          appeal.Role,
4✔
386
                                                        "account_id":    appeal.AccountID,
4✔
387
                                                        "appeal_id":     appeal.ID,
4✔
388
                                                        "requestor":     appeal.CreatedBy,
4✔
389
                                                },
4✔
390
                                        },
4✔
391
                                })
4✔
392

4✔
393
                                notifications = addOnBehalfApprovedNotification(appeal, notifications)
4✔
394
                        }
395
                }
396
        }
397

398
        // Skip database persistence in dry run mode
399
        if !isDryRun {
22✔
400
                if err := s.repo.BulkUpsert(ctx, appeals); err != nil {
12✔
401
                        return fmt.Errorf("inserting appeals into db: %w", err)
1✔
402
                }
1✔
403

404
                go func() {
20✔
405
                        ctx := context.WithoutCancel(ctx)
10✔
406
                        if err := s.auditLogger.Log(ctx, AuditKeyBulkInsert, appeals); err != nil {
10✔
UNCOV
407
                                s.logger.Error(ctx, "failed to record audit log", "error", err)
×
UNCOV
408
                        }
×
409
                }()
410
        }
411

412
        for _, a := range appeals {
23✔
413
                if a.Status == domain.AppealStatusRejected {
13✔
UNCOV
414
                        var reason string
×
UNCOV
415
                        for _, approval := range a.Approvals {
×
UNCOV
416
                                if approval.Status == domain.ApprovalStatusRejected {
×
417
                                        reason = approval.Reason
×
418
                                        break
×
419
                                }
420
                        }
421

422
                        notifications = append(notifications, domain.Notification{
×
423
                                User: a.CreatedBy,
×
424
                                Labels: map[string]string{
×
425
                                        "appeal_id": a.ID,
×
426
                                },
×
427
                                Message: domain.NotificationMessage{
×
428
                                        Type: domain.NotificationTypeAppealRejected,
×
429
                                        Variables: map[string]interface{}{
×
430
                                                "resource_name": fmt.Sprintf("%s (%s: %s)", a.Resource.Name, a.Resource.ProviderType, a.Resource.URN),
×
431
                                                "role":          a.Role,
×
432
                                                "account_id":    a.AccountID,
×
433
                                                "appeal_id":     a.ID,
×
UNCOV
434
                                                "requestor":     a.CreatedBy,
×
UNCOV
435
                                                "reason":        reason,
×
UNCOV
436
                                        },
×
UNCOV
437
                                },
×
UNCOV
438
                        })
×
439
                }
440

441
                notifications = append(notifications, s.getApprovalNotifications(ctx, a)...)
13✔
442
        }
443

444
        // Skip notifications in dry run mode
445
        if !isDryRun && len(notifications) > 0 {
20✔
446
                go func() {
20✔
447
                        ctx := context.WithoutCancel(ctx)
10✔
448
                        if errs := s.notifier.Notify(ctx, notifications); errs != nil {
10✔
UNCOV
449
                                for _, err1 := range errs {
×
UNCOV
450
                                        s.logger.Error(ctx, "failed to send notifications", "error", err1.Error())
×
UNCOV
451
                                }
×
452
                        }
453
                }()
454
        }
455

456
        return nil
10✔
457
}
458

459
func (s *Service) findActiveGrant(ctx context.Context, a *domain.Appeal) (*domain.Grant, error) {
37✔
460
        grants, err := s.grantService.List(ctx, domain.ListGrantsFilter{
37✔
461
                Statuses:    []string{string(domain.GrantStatusActive)},
37✔
462
                AccountIDs:  []string{a.AccountID},
37✔
463
                ResourceIDs: []string{a.ResourceID},
37✔
464
                Roles:       []string{a.Role},
37✔
465
                OrderBy:     []string{"updated_at:desc"},
37✔
466
        })
37✔
467

37✔
468
        if err != nil {
37✔
UNCOV
469
                return nil, fmt.Errorf("listing active grants: %w", err)
×
UNCOV
470
        }
×
471

472
        if len(grants) == 0 {
51✔
473
                return nil, ErrGrantNotFound
14✔
474
        }
14✔
475

476
        return &grants[0], nil
23✔
477
}
478

479
func addOnBehalfApprovedNotification(appeal *domain.Appeal, notifications []domain.Notification) []domain.Notification {
6✔
480
        if appeal.AccountType == domain.DefaultAppealAccountType && appeal.AccountID != appeal.CreatedBy {
6✔
481
                notifications = append(notifications, domain.Notification{
×
482
                        User: appeal.AccountID,
×
483
                        Labels: map[string]string{
×
484
                                "appeal_id": appeal.ID,
×
485
                        },
×
486
                        Message: domain.NotificationMessage{
×
487
                                Type: domain.NotificationTypeOnBehalfAppealApproved,
×
488
                                Variables: map[string]interface{}{
×
489
                                        "appeal_id":     appeal.ID,
×
490
                                        "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
×
491
                                        "role":          appeal.Role,
×
492
                                        "account_id":    appeal.AccountID,
×
UNCOV
493
                                        "requestor":     appeal.CreatedBy,
×
UNCOV
494
                                },
×
UNCOV
495
                        },
×
UNCOV
496
                })
×
UNCOV
497
        }
×
498
        return notifications
6✔
499
}
500

501
func validateAppealDurationConfig(appeal *domain.Appeal, policy *domain.Policy) error {
25✔
502
        // return nil if duration options are not configured for this policy
25✔
503
        if policy.AppealConfig == nil || policy.AppealConfig.DurationOptions == nil {
48✔
504
                return nil
23✔
505
        }
23✔
506
        for _, durationOption := range policy.AppealConfig.DurationOptions {
8✔
507
                if appeal.Options.Duration == durationOption.Value {
6✔
UNCOV
508
                        return nil
×
UNCOV
509
                }
×
510
        }
511

512
        return fmt.Errorf("invalid duration: %w: %q", ErrDurationNotAllowed, appeal.Options.Duration)
2✔
513
}
514

515
func validateAppealOnBehalf(a *domain.Appeal, policy *domain.Policy) error {
23✔
516
        if a.AccountType == domain.DefaultAppealAccountType {
46✔
517
                if policy.AppealConfig != nil && policy.AppealConfig.AllowOnBehalf {
38✔
518
                        return nil
15✔
519
                }
15✔
520
                if a.AccountID != a.CreatedBy {
10✔
521
                        return ErrCannotCreateAppealForOtherUser
2✔
522
                }
2✔
523
        }
524
        return nil
6✔
525
}
526

527
// Patch record
528
func (s *Service) Patch(ctx context.Context, appeal *domain.Appeal) error {
27✔
529
        existingAppeal, err := s.GetByID(ctx, appeal.ID)
27✔
530
        if err != nil {
28✔
531
                return fmt.Errorf("error getting existing appeal: %w", err)
1✔
532
        }
1✔
533

534
        if existingAppeal.Status != domain.AppealStatusPending {
27✔
535
                return fmt.Errorf("%w: unable to edit appeal in status: %q", ErrAppealStatusInvalid, existingAppeal.Status)
1✔
536
        }
1✔
537

538
        isAppealUpdated, err := validatePatchReq(appeal, existingAppeal)
25✔
539
        if err != nil {
25✔
UNCOV
540
                return err
×
UNCOV
541
        }
×
542

543
        if !isAppealUpdated {
26✔
544
                return ErrNoChanges
1✔
545
        }
1✔
546

547
        eg, egctx := errgroup.WithContext(ctx)
24✔
548
        var (
24✔
549
                providers      map[string]map[string]*domain.Provider
24✔
550
                policies       map[string]map[uint]*domain.Policy
24✔
551
                pendingAppeals map[string]map[string]map[string]*domain.Appeal
24✔
552
        )
24✔
553

24✔
554
        eg.Go(func() error {
48✔
555
                if appeal.Resource == nil {
43✔
556
                        resource, err := s.resourceService.Get(egctx, &domain.ResourceIdentifier{ID: appeal.ResourceID})
19✔
557
                        if err != nil {
20✔
558
                                return fmt.Errorf("error getting resource: %w", err)
1✔
559
                        }
1✔
560
                        appeal.Resource = resource
18✔
561
                }
562
                return nil
23✔
563
        })
564

565
        eg.Go(func() error {
48✔
566
                providersData, err := s.getProvidersMap(egctx)
24✔
567
                if err != nil {
25✔
568
                        return fmt.Errorf("error getting providers map: %w", err)
1✔
569
                }
1✔
570
                providers = providersData
23✔
571
                return nil
23✔
572
        })
573

574
        eg.Go(func() error {
48✔
575
                policiesData, err := s.getPoliciesMap(egctx)
24✔
576
                if err != nil {
25✔
577
                        return fmt.Errorf("error getting policies map: %w", err)
1✔
578
                }
1✔
579
                policies = policiesData
23✔
580
                return nil
23✔
581
        })
582

583
        eg.Go(func() error {
48✔
584
                pendingAppealsData, err := s.getAppealsMap(egctx, &domain.ListAppealsFilter{
24✔
585
                        Statuses:   []string{domain.AppealStatusPending},
24✔
586
                        AccountIDs: []string{appeal.AccountID},
24✔
587
                })
24✔
588
                if err != nil {
25✔
589
                        return fmt.Errorf("error while listing pending appeals: %w", err)
1✔
590
                }
1✔
591
                pendingAppeals = pendingAppealsData
23✔
592
                return nil
23✔
593
        })
594

595
        if err := eg.Wait(); err != nil {
28✔
596
                return err
4✔
597
        }
4✔
598

599
        appeal.SetDefaults()
20✔
600

20✔
601
        if appeal.AccountID != existingAppeal.AccountID || appeal.ResourceID != existingAppeal.ResourceID || appeal.Role != existingAppeal.Role {
33✔
602
                if err := validateAppeal(appeal, pendingAppeals); err != nil {
14✔
603
                        return err
1✔
604
                }
1✔
605
        }
606

607
        // Validate package RAM role usage if account_id, group_id, or group_type changed
608
        if appeal.AccountID != existingAppeal.AccountID || appeal.GroupID != existingAppeal.GroupID || appeal.GroupType != existingAppeal.GroupType {
20✔
609
                if err := s.validatePackageRAMRoleUsage(ctx, appeal); err != nil {
1✔
UNCOV
610
                        return fmt.Errorf("package RAM role validation: %w", err)
×
UNCOV
611
                }
×
612
        }
613

614
        provider, err := getProvider(appeal, providers)
19✔
615
        if err != nil {
21✔
616
                return err
2✔
617
        }
2✔
618

619
        policy, err := getPolicy(appeal, provider, policies)
17✔
620
        if err != nil {
20✔
621
                return err
3✔
622
        }
3✔
623

624
        activeGrant, err := s.findActiveGrant(ctx, appeal)
14✔
625
        if err != nil && err != ErrGrantNotFound {
14✔
UNCOV
626
                return err
×
UNCOV
627
        }
×
628

629
        if activeGrant != nil {
23✔
630
                if err := s.checkExtensionEligibility(appeal, provider, policy, activeGrant); err != nil {
12✔
631
                        return err
3✔
632
                }
3✔
633
        }
634

635
        if err := s.providerService.ValidateAppeal(ctx, appeal, provider, policy); err != nil {
13✔
636
                return fmt.Errorf("provider validation: %w", err)
2✔
637
        }
2✔
638

639
        strPermissions, err := s.getPermissions(ctx, provider.Config, appeal.Resource.Type, appeal.Role)
9✔
640
        if err != nil {
9✔
UNCOV
641
                return fmt.Errorf("getting permissions list: %w", err)
×
642
        }
×
643
        appeal.Permissions = strPermissions
9✔
644

9✔
645
        if err := validateAppealDurationConfig(appeal, policy); err != nil {
10✔
646
                return err
1✔
647
        }
1✔
648

649
        if err := validateAppealOnBehalf(appeal, policy); err != nil {
9✔
650
                return err
1✔
651
        }
1✔
652

653
        if err := s.populateAppealMetadata(ctx, appeal, policy); err != nil {
7✔
UNCOV
654
                return fmt.Errorf("getting appeal metadata: %w", err)
×
UNCOV
655
        }
×
656

657
        if err := s.addCreatorDetails(ctx, appeal, policy); err != nil {
7✔
UNCOV
658
                return fmt.Errorf("getting creator details: %w", err)
×
UNCOV
659
        }
×
660

661
        // create new approval
662
        appeal.Revision = existingAppeal.Revision + 1
7✔
663
        if err := appeal.ApplyPolicy(policy); err != nil {
7✔
664
                return err
×
665
        }
×
666

667
        if err := appeal.AdvanceApproval(policy); err != nil {
7✔
668
                return fmt.Errorf("initializing approvals: %w", err)
×
669
        }
×
670
        appeal.Policy = nil
7✔
671

7✔
672
        notifications := []domain.Notification{}
7✔
673
        for _, approval := range appeal.Approvals {
19✔
674
                if approval.Index == len(appeal.Approvals)-1 && (approval.Status == domain.ApprovalStatusApproved || appeal.Status == domain.AppealStatusApproved) {
12✔
675
                        newGrant, revokedGrant, err := s.prepareGrant(ctx, appeal)
×
676
                        if err != nil {
×
677
                                return fmt.Errorf("preparing grant: %w", err)
×
678
                        }
×
679
                        newGrant.Resource = appeal.Resource
×
UNCOV
680
                        appeal.Grant = newGrant
×
UNCOV
681
                        if revokedGrant != nil {
×
682
                                if _, err := s.grantService.Revoke(ctx, revokedGrant.ID, domain.SystemActorName, revokedGrant.RevokeReason,
×
683
                                        grant.SkipNotifications(),
×
684
                                        grant.SkipRevokeAccessInProvider(),
×
685
                                ); err != nil {
×
686
                                        return fmt.Errorf("revoking previous grant: %w", err)
×
687
                                }
×
688
                        } else {
×
689
                                if err := s.GrantAccessToProvider(ctx, appeal); err != nil {
×
690
                                        return fmt.Errorf("granting access: %w", err)
×
691
                                }
×
692
                        }
693

694
                        notifications = append(notifications, domain.Notification{
×
695
                                User: appeal.CreatedBy,
×
696
                                Labels: map[string]string{
×
697
                                        "appeal_id": appeal.ID,
×
698
                                },
×
699
                                Message: domain.NotificationMessage{
×
UNCOV
700
                                        Type: domain.NotificationTypeAppealApproved,
×
UNCOV
701
                                        Variables: map[string]interface{}{
×
UNCOV
702
                                                "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
×
UNCOV
703
                                                "role":          appeal.Role,
×
UNCOV
704
                                                "account_id":    appeal.AccountID,
×
UNCOV
705
                                                "appeal_id":     appeal.ID,
×
UNCOV
706
                                                "requestor":     appeal.CreatedBy,
×
UNCOV
707
                                        },
×
UNCOV
708
                                },
×
UNCOV
709
                        })
×
UNCOV
710

×
UNCOV
711
                        notifications = addOnBehalfApprovedNotification(appeal, notifications)
×
712
                }
713
        }
714

715
        newApprovals := appeal.Approvals
7✔
716

7✔
717
        // mark previous approvals as stale
7✔
718
        for _, approval := range existingAppeal.Approvals {
19✔
719
                approval.IsStale = true
12✔
720

12✔
721
                // clear approvers so it won't get inserted to db
12✔
722
                // TODO: change Approvers type to Approver[] instead of string[] to keep each ID
12✔
723
                approval.Approvers = []string{}
12✔
724

12✔
725
                appeal.Approvals = append(appeal.Approvals, approval)
12✔
726
        }
12✔
727

728
        if err := s.repo.UpdateByID(ctx, appeal); err != nil {
8✔
729
                return fmt.Errorf("error saving appeal to db: %w", err)
1✔
730
        }
1✔
731

732
        diff, err := appeal.Compare(existingAppeal, appeal.CreatedBy)
6✔
733
        if err != nil {
6✔
734
                return fmt.Errorf("error comparing appeals: %w", err)
×
UNCOV
735
        }
×
736

737
        auditLog := map[string]interface{}{
6✔
738
                "appeal_id": appeal.ID,
6✔
739
                "revision":  appeal.Revision,
6✔
740
                "diff":      diff,
6✔
741
        }
6✔
742
        go func() {
12✔
743
                ctx := context.WithoutCancel(ctx)
6✔
744
                if err := s.auditLogger.Log(ctx, AuditKeyUpdate, auditLog); err != nil {
6✔
745
                        s.logger.Error(ctx, "failed to record audit log", "error", err)
×
746
                }
×
747
        }()
748

749
        appeal.Approvals = newApprovals
6✔
750
        if appeal.Status == domain.AppealStatusApproved {
6✔
751
                notifications = append(notifications, domain.Notification{
×
752
                        User: appeal.CreatedBy,
×
753
                        Labels: map[string]string{
×
754
                                "appeal_id": appeal.ID,
×
755
                        },
×
UNCOV
756
                        Message: domain.NotificationMessage{
×
757
                                Type: domain.NotificationTypeAppealApproved,
×
758
                                Variables: map[string]interface{}{
×
759
                                        "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
×
760
                                        "role":          appeal.Role,
×
761
                                        "account_id":    appeal.AccountID,
×
UNCOV
762
                                        "appeal_id":     appeal.ID,
×
UNCOV
763
                                        "requestor":     appeal.CreatedBy,
×
764
                                },
×
765
                        },
×
766
                })
×
767
                notifications = addOnBehalfApprovedNotification(appeal, notifications)
×
768
        } else if appeal.Status == domain.AppealStatusRejected {
6✔
769
                var reason string
×
770
                for _, approval := range appeal.Approvals {
×
771
                        if approval.Status == domain.ApprovalStatusRejected {
×
772
                                reason = approval.Reason
×
773
                                break
×
774
                        }
775
                }
776
                notifications = append(notifications, domain.Notification{
×
777
                        User: appeal.CreatedBy,
×
778
                        Labels: map[string]string{
×
779
                                "appeal_id": appeal.ID,
×
780
                        },
×
UNCOV
781
                        Message: domain.NotificationMessage{
×
UNCOV
782
                                Type: domain.NotificationTypeAppealRejected,
×
UNCOV
783
                                Variables: map[string]interface{}{
×
UNCOV
784
                                        "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
×
UNCOV
785
                                        "role":          appeal.Role,
×
UNCOV
786
                                        "account_id":    appeal.AccountID,
×
UNCOV
787
                                        "appeal_id":     appeal.ID,
×
UNCOV
788
                                        "requestor":     appeal.CreatedBy,
×
789
                                        "reason":        reason,
×
790
                                },
×
791
                        },
×
UNCOV
792
                })
×
793
        } else {
6✔
794
                notifications = append(notifications, s.getApprovalNotifications(ctx, appeal)...)
6✔
795
        }
6✔
796

797
        if len(notifications) > 0 {
12✔
798
                go func() {
12✔
799
                        ctx := context.WithoutCancel(ctx)
6✔
800
                        if errs := s.notifier.Notify(ctx, notifications); errs != nil {
6✔
UNCOV
801
                                for _, err1 := range errs {
×
UNCOV
802
                                        s.logger.Error(ctx, "failed to send notifications", "error", err1.Error())
×
UNCOV
803
                                }
×
804
                        }
805
                }()
806
        }
807

808
        return nil
6✔
809
}
810

811
func validatePatchReq(appeal, existingAppeal *domain.Appeal) (bool, error) {
25✔
812
        var isAppealUpdated bool
25✔
813

25✔
814
        updateField := func(newVal, existingVal string) string {
175✔
815
                if newVal == "" || newVal == existingVal {
286✔
816
                        return existingVal
136✔
817
                }
136✔
818
                isAppealUpdated = true
14✔
819
                return newVal
14✔
820
        }
821

822
        appeal.AccountID = updateField(appeal.AccountID, existingAppeal.AccountID)
25✔
823
        appeal.AccountType = updateField(appeal.AccountType, existingAppeal.AccountType)
25✔
824
        appeal.Description = updateField(appeal.Description, existingAppeal.Description)
25✔
825
        appeal.Role = updateField(appeal.Role, existingAppeal.Role)
25✔
826
        appeal.ResourceID = updateField(appeal.ResourceID, existingAppeal.ResourceID)
25✔
827
        if appeal.ResourceID == existingAppeal.ResourceID {
40✔
828
                appeal.Resource = existingAppeal.Resource
15✔
829
        }
15✔
830

831
        if appeal.Options == nil || reflect.DeepEqual(appeal.Options, existingAppeal.Options) {
40✔
832
                appeal.Options = existingAppeal.Options
15✔
833
        } else {
25✔
834
                isAppealUpdated = true
10✔
835
        }
10✔
836

837
        if appeal.Details == nil || reflect.DeepEqual(appeal.Details, existingAppeal.Details) {
48✔
838
                appeal.Details = existingAppeal.Details
23✔
839
        } else {
25✔
840
                for key, value := range appeal.Details {
5✔
841
                        if existingValue, found := existingAppeal.Details[key]; !found || !reflect.DeepEqual(existingValue, value) {
4✔
842
                                isAppealUpdated = true
1✔
843
                        }
1✔
844
                }
845
        }
846

847
        if appeal.Labels == nil || reflect.DeepEqual(appeal.Labels, existingAppeal.Labels) {
49✔
848
                appeal.Labels = existingAppeal.Labels
24✔
849
        } else {
25✔
850
                isAppealUpdated = true
1✔
851
        }
1✔
852

853
        appeal.CreatedBy = updateField(appeal.CreatedBy, existingAppeal.CreatedBy)
25✔
854
        if appeal.CreatedBy != existingAppeal.CreatedBy {
25✔
UNCOV
855
                return false, fmt.Errorf("not allowed to update creator")
×
UNCOV
856
        }
×
857

858
        appeal.Creator = existingAppeal.Creator
25✔
859
        appeal.Status = existingAppeal.Status
25✔
860

25✔
861
        return isAppealUpdated, nil
25✔
862
}
863

864
// UpdateApproval Approve an approval step
865
func (s *Service) UpdateApproval(ctx context.Context, approvalAction domain.ApprovalAction) (*domain.Appeal, error) {
28✔
866
        if err := approvalAction.Validate(); err != nil {
33✔
867
                return nil, fmt.Errorf("%w: %v", ErrInvalidUpdateApprovalParameter, err)
5✔
868
        }
5✔
869

870
        appeal, err := s.GetByID(ctx, approvalAction.AppealID)
23✔
871
        if err != nil {
25✔
872
                if errors.Is(err, ErrAppealNotFound) {
3✔
873
                        return nil, fmt.Errorf("%w: %q", ErrAppealNotFound, approvalAction.AppealID)
1✔
874
                }
1✔
875
                return nil, err
1✔
876
        }
877

878
        if err := checkIfAppealStatusStillPending(appeal.Status); err != nil {
25✔
879
                return nil, err
4✔
880
        }
4✔
881

882
        currentApproval := appeal.GetApproval(approvalAction.ApprovalName)
17✔
883
        if currentApproval == nil {
18✔
884
                return nil, fmt.Errorf("%w: %q", ErrApprovalNotFound, approvalAction.ApprovalName)
1✔
885
        }
1✔
886

887
        // validate previous approvals status
888
        for i := 0; i < currentApproval.Index; i++ {
29✔
889
                prevApproval := appeal.GetApprovalByIndex(i)
13✔
890
                if prevApproval == nil {
13✔
UNCOV
891
                        return nil, fmt.Errorf("unable to find approval with index %d", i)
×
UNCOV
892
                }
×
893
                if err := checkPreviousApprovalStatus(prevApproval.Status, prevApproval.Name); err != nil {
16✔
894
                        return nil, err
3✔
895
                }
3✔
896
        }
897

898
        // validate current approval status
899
        if err := checkApprovalStatus(currentApproval.Status); err != nil {
17✔
900
                return nil, err
4✔
901
        }
4✔
902
        if !currentApproval.IsExistingApprover(approvalAction.Actor) {
10✔
903
                return nil, ErrActionForbidden
1✔
904
        }
1✔
905

906
        // update approval
907
        currentApproval.Actor = &approvalAction.Actor
8✔
908
        currentApproval.Reason = approvalAction.Reason
8✔
909
        currentApproval.UpdatedAt = TimeNow()
8✔
910
        if approvalAction.Action == domain.AppealActionNameApprove {
13✔
911
                if appeal.Policy == nil {
10✔
912
                        appeal.Policy, err = s.policyService.GetOne(ctx, appeal.PolicyID, appeal.PolicyVersion)
5✔
913
                        if err != nil {
6✔
914
                                return nil, err
1✔
915
                        }
1✔
916
                }
917

918
                policyStep := appeal.Policy.GetStepByName(currentApproval.Name)
4✔
919
                if policyStep == nil {
4✔
UNCOV
920
                        return nil, fmt.Errorf("%w: %q for appeal %q", ErrNoPolicyStepFound, approvalAction.ApprovalName, appeal.ID)
×
UNCOV
921
                }
×
922

923
                // check if user is self approving the appeal
924
                if policyStep.DontAllowSelfApproval {
5✔
925
                        if approvalAction.Actor == appeal.CreatedBy {
2✔
926
                                return nil, ErrSelfApprovalNotAllowed
1✔
927
                        }
1✔
928
                }
929

930
                currentApproval.Approve()
3✔
931

3✔
932
                // mark next approval as pending
3✔
933
                nextApproval := appeal.GetApprovalByIndex(currentApproval.Index + 1)
3✔
934
                if nextApproval != nil {
4✔
935
                        nextApproval.Status = domain.ApprovalStatusPending
1✔
936
                }
1✔
937

938
                if err := appeal.AdvanceApproval(appeal.Policy); err != nil {
3✔
UNCOV
939
                        return nil, err
×
UNCOV
940
                }
×
941
        } else if approvalAction.Action == domain.AppealActionNameReject {
6✔
942
                currentApproval.Reject()
3✔
943
                appeal.Reject()
3✔
944

3✔
945
                // mark the rest of approvals as skipped
3✔
946
                i := currentApproval.Index
3✔
947
                for {
7✔
948
                        nextApproval := appeal.GetApprovalByIndex(i + 1)
4✔
949
                        if nextApproval == nil {
7✔
950
                                break
3✔
951
                        }
952
                        nextApproval.Skip()
1✔
953
                        nextApproval.UpdatedAt = TimeNow()
1✔
954
                        i++
1✔
955
                }
UNCOV
956
        } else {
×
UNCOV
957
                return nil, ErrActionInvalidValue
×
UNCOV
958
        }
×
959

960
        // evaluate final appeal status
961
        if appeal.Status == domain.AppealStatusApproved {
8✔
962
                newGrant, prevGrant, err := s.prepareGrant(ctx, appeal)
2✔
963
                if err != nil {
2✔
UNCOV
964
                        return nil, fmt.Errorf("preparing grant: %w", err)
×
UNCOV
965
                }
×
966
                newGrant.Resource = appeal.Resource
2✔
967
                appeal.Grant = newGrant
2✔
968
                if prevGrant != nil {
3✔
969
                        if _, err := s.grantService.Revoke(ctx, prevGrant.ID, domain.SystemActorName, prevGrant.RevokeReason,
1✔
970
                                grant.SkipNotifications(),
1✔
971
                                grant.SkipRevokeAccessInProvider(),
1✔
972
                        ); err != nil {
1✔
973
                                return nil, fmt.Errorf("revoking previous grant: %w", err)
×
974
                        }
×
975
                }
976

977
                if err := s.GrantAccessToProvider(ctx, appeal); err != nil {
2✔
UNCOV
978
                        return nil, fmt.Errorf("granting access: %w", err)
×
UNCOV
979
                }
×
980
        }
981

982
        if err := s.Update(ctx, appeal); err != nil {
6✔
UNCOV
983
                if !errors.Is(err, domain.ErrDuplicateActiveGrant) {
×
UNCOV
984
                        if err := s.providerService.RevokeAccess(ctx, *appeal.Grant); err != nil {
×
UNCOV
985
                                return nil, fmt.Errorf("revoking access: %w", err)
×
UNCOV
986
                        }
×
987
                }
UNCOV
988
                return nil, fmt.Errorf("updating appeal: %w", err)
×
989
        }
990

991
        notifications := []domain.Notification{}
6✔
992
        if appeal.Status == domain.AppealStatusApproved {
8✔
993
                notifications = append(notifications, domain.Notification{
2✔
994
                        User: appeal.CreatedBy,
2✔
995
                        Labels: map[string]string{
2✔
996
                                "appeal_id": appeal.ID,
2✔
997
                        },
2✔
998
                        Message: domain.NotificationMessage{
2✔
999
                                Type: domain.NotificationTypeAppealApproved,
2✔
1000
                                Variables: map[string]interface{}{
2✔
1001
                                        "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
2✔
1002
                                        "role":          appeal.Role,
2✔
1003
                                        "account_id":    appeal.AccountID,
2✔
1004
                                        "appeal_id":     appeal.ID,
2✔
1005
                                        "requestor":     appeal.CreatedBy,
2✔
1006
                                },
2✔
1007
                        },
2✔
1008
                })
2✔
1009
                notifications = addOnBehalfApprovedNotification(appeal, notifications)
2✔
1010
        } else if appeal.Status == domain.AppealStatusRejected {
9✔
1011
                notifications = append(notifications, domain.Notification{
3✔
1012
                        User: appeal.CreatedBy,
3✔
1013
                        Labels: map[string]string{
3✔
1014
                                "appeal_id": appeal.ID,
3✔
1015
                        },
3✔
1016
                        Message: domain.NotificationMessage{
3✔
1017
                                Type: domain.NotificationTypeAppealRejected,
3✔
1018
                                Variables: map[string]interface{}{
3✔
1019
                                        "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
3✔
1020
                                        "role":          appeal.Role,
3✔
1021
                                        "account_id":    appeal.AccountID,
3✔
1022
                                        "appeal_id":     appeal.ID,
3✔
1023
                                        "requestor":     appeal.CreatedBy,
3✔
1024
                                },
3✔
1025
                        },
3✔
1026
                })
3✔
1027
        } else {
4✔
1028
                notifications = append(notifications, s.getApprovalNotifications(ctx, appeal)...)
1✔
1029
        }
1✔
1030
        if len(notifications) > 0 {
12✔
1031
                go func() {
12✔
1032
                        ctx := context.WithoutCancel(ctx)
6✔
1033
                        if errs := s.notifier.Notify(ctx, notifications); errs != nil {
6✔
UNCOV
1034
                                for _, err1 := range errs {
×
UNCOV
1035
                                        s.logger.Error(ctx, "failed to send notifications", "error", err1.Error())
×
UNCOV
1036
                                }
×
1037
                        }
1038
                }()
1039
        }
1040

1041
        var auditKey string
6✔
1042
        if approvalAction.Action == string(domain.ApprovalActionReject) {
9✔
1043
                auditKey = AuditKeyReject
3✔
1044
        } else if approvalAction.Action == string(domain.ApprovalActionApprove) {
9✔
1045
                auditKey = AuditKeyApprove
3✔
1046
        }
3✔
1047
        if auditKey != "" {
12✔
1048
                go func() {
12✔
1049
                        ctx := context.WithoutCancel(ctx)
6✔
1050
                        if err := s.auditLogger.Log(ctx, auditKey, approvalAction); err != nil {
6✔
UNCOV
1051
                                s.logger.Error(ctx, "failed to record audit log", "error", err)
×
UNCOV
1052
                        }
×
1053
                }()
1054
        }
1055

1056
        return appeal, nil
6✔
1057
}
1058

1059
func (s *Service) Update(ctx context.Context, appeal *domain.Appeal) error {
6✔
1060
        return s.repo.Update(ctx, appeal)
6✔
1061
}
6✔
1062

1063
func (s *Service) Cancel(ctx context.Context, id string) (*domain.Appeal, error) {
2✔
1064
        if id == "" {
3✔
1065
                return nil, ErrAppealIDEmptyParam
1✔
1066
        }
1✔
1067

1068
        if !utils.IsValidUUID(id) {
2✔
1069
                return nil, InvalidError{AppealID: id}
1✔
1070
        }
1✔
1071

1072
        appeal, err := s.GetByID(ctx, id)
×
1073
        if err != nil {
×
1074
                return nil, err
×
UNCOV
1075
        }
×
1076

1077
        // TODO: check only appeal creator who is allowed to cancel the appeal
1078

1079
        if err := checkIfAppealStatusStillPending(appeal.Status); err != nil {
×
1080
                return nil, err
×
1081
        }
×
1082

UNCOV
1083
        appeal.Cancel()
×
UNCOV
1084
        if err := s.repo.Update(ctx, appeal); err != nil {
×
1085
                return nil, err
×
UNCOV
1086
        }
×
1087

UNCOV
1088
        go func() {
×
UNCOV
1089
                ctx := context.WithoutCancel(ctx)
×
UNCOV
1090
                if err := s.auditLogger.Log(ctx, AuditKeyCancel, map[string]interface{}{
×
UNCOV
1091
                        "appeal_id": id,
×
UNCOV
1092
                }); err != nil {
×
UNCOV
1093
                        s.logger.Error(ctx, "failed to record audit log", "error", err)
×
UNCOV
1094
                }
×
1095
        }()
1096

UNCOV
1097
        return appeal, nil
×
1098
}
1099

1100
func (s *Service) AddApprover(ctx context.Context, appealID, approvalID, email string) (*domain.Appeal, error) {
14✔
1101
        if err := s.validator.Var(email, "email"); err != nil {
16✔
1102
                return nil, fmt.Errorf("%w: %s", ErrApproverEmail, err)
2✔
1103
        }
2✔
1104

1105
        appeal, approval, err := s.getApproval(ctx, appealID, approvalID)
12✔
1106
        if err != nil {
16✔
1107
                return nil, err
4✔
1108
        }
4✔
1109
        if appeal.Status != domain.AppealStatusPending {
9✔
1110
                return nil, fmt.Errorf("%w: can't add new approver to appeal with %q status", ErrUnableToAddApprover, appeal.Status)
1✔
1111
        }
1✔
1112
        if approval.IsStale {
8✔
1113
                return nil, fmt.Errorf("%w: can't add new approver to a stale approval", ErrUnableToAddApprover)
1✔
1114
        }
1✔
1115
        if approval.IsExistingApprover(email) {
7✔
1116
                return nil, fmt.Errorf("%w: approver %q already exists", ErrUnableToAddApprover, email)
1✔
1117
        }
1✔
1118

1119
        switch approval.Status {
5✔
1120
        case domain.ApprovalStatusPending:
3✔
1121
                break
3✔
1122
        case domain.ApprovalStatusBlocked:
1✔
1123
                // check if approval type is auto
1✔
1124
                // this approach is the quickest way to assume that approval is auto, otherwise need to fetch the policy details and lookup the approval type which takes more time
1✔
1125
                if approval.Approvers == nil || len(approval.Approvers) == 0 {
2✔
1126
                        // approval is automatic (strategy: auto) that is still on blocked
1✔
1127
                        return nil, fmt.Errorf("%w: can't modify approvers for approval with strategy auto", ErrUnableToAddApprover)
1✔
1128
                }
1✔
1129
        default:
1✔
1130
                return nil, fmt.Errorf("%w: can't add approver to approval with %q status", ErrUnableToAddApprover, approval.Status)
1✔
1131
        }
1132

1133
        if err := s.approvalService.AddApprover(ctx, approval.ID, email); err != nil {
4✔
1134
                return nil, fmt.Errorf("adding new approver: %w", err)
1✔
1135
        }
1✔
1136
        approval.Approvers = append(approval.Approvers, email)
2✔
1137

2✔
1138
        auditData, err := utils.StructToMap(approval)
2✔
1139
        if err != nil {
2✔
1140
                return nil, fmt.Errorf("converting approval to map: %w", err)
×
1141
        }
×
1142
        auditData["affected_approver"] = email
2✔
1143
        go func() {
4✔
1144
                ctx := context.WithoutCancel(ctx)
2✔
1145
                if err := s.auditLogger.Log(ctx, AuditKeyAddApprover, auditData); err != nil {
2✔
UNCOV
1146
                        s.logger.Error(ctx, "failed to record audit log", "error", err)
×
UNCOV
1147
                }
×
1148
        }()
1149

1150
        duration := domain.PermanentDurationLabel
2✔
1151
        if !appeal.IsDurationEmpty() {
2✔
UNCOV
1152
                duration, err = utils.GetReadableDuration(appeal.Options.Duration)
×
UNCOV
1153
                if err != nil {
×
UNCOV
1154
                        s.logger.Error(ctx, "failed to get readable duration", "error", err, "appeal_id", appeal.ID)
×
UNCOV
1155
                }
×
1156
        }
1157

1158
        go func() {
4✔
1159
                ctx := context.WithoutCancel(ctx)
2✔
1160
                if errs := s.notifier.Notify(ctx, []domain.Notification{
2✔
1161
                        {
2✔
1162
                                User: email,
2✔
1163
                                Labels: map[string]string{
2✔
1164
                                        "appeal_id": appeal.ID,
2✔
1165
                                },
2✔
1166
                                Message: domain.NotificationMessage{
2✔
1167
                                        Type: domain.NotificationTypeApproverNotification,
2✔
1168
                                        Variables: map[string]interface{}{
2✔
1169
                                                "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
2✔
1170
                                                "role":          appeal.Role,
2✔
1171
                                                "requestor":     appeal.CreatedBy,
2✔
1172
                                                "appeal_id":     appeal.ID,
2✔
1173
                                                "account_id":    appeal.AccountID,
2✔
1174
                                                "account_type":  appeal.AccountType,
2✔
1175
                                                "provider_type": appeal.Resource.ProviderType,
2✔
1176
                                                "resource_type": appeal.Resource.Type,
2✔
1177
                                                "created_at":    appeal.CreatedAt,
2✔
1178
                                                "approval_step": approval.Name,
2✔
1179
                                                "actor":         email,
2✔
1180
                                                "details":       appeal.Details,
2✔
1181
                                                "duration":      duration,
2✔
1182
                                                "creator":       appeal.Creator,
2✔
1183
                                        },
2✔
1184
                                },
2✔
1185
                        },
2✔
1186
                }); errs != nil {
2✔
UNCOV
1187
                        for _, err1 := range errs {
×
UNCOV
1188
                                s.logger.Error(ctx, "failed to send notifications", "error", err1.Error())
×
UNCOV
1189
                        }
×
1190
                }
1191
        }()
1192

1193
        return appeal, nil
2✔
1194
}
1195

1196
func (s *Service) DeleteApprover(ctx context.Context, appealID, approvalID, email string) (*domain.Appeal, error) {
13✔
1197
        if err := s.validator.Var(email, "email"); err != nil {
15✔
1198
                return nil, fmt.Errorf("%w: %s", ErrApproverEmail, err)
2✔
1199
        }
2✔
1200

1201
        appeal, approval, err := s.getApproval(ctx, appealID, approvalID)
11✔
1202
        if err != nil {
14✔
1203
                return nil, err
3✔
1204
        }
3✔
1205
        if appeal.Status != domain.AppealStatusPending {
9✔
1206
                return nil, fmt.Errorf("%w: can't delete approver to appeal with %q status", ErrUnableToDeleteApprover, appeal.Status)
1✔
1207
        }
1✔
1208
        if approval.IsStale {
8✔
1209
                return nil, fmt.Errorf("%w: can't delete approver in a stale approval", ErrUnableToDeleteApprover)
1✔
1210
        }
1✔
1211

1212
        switch approval.Status {
6✔
1213
        case domain.ApprovalStatusPending:
3✔
1214
                break
3✔
1215
        case domain.ApprovalStatusBlocked:
2✔
1216
                // check if approval type is auto
2✔
1217
                // this approach is the quickest way to assume that approval is auto, otherwise need to fetch the policy details and lookup the approval type which takes more time
2✔
1218
                if approval.Approvers == nil || len(approval.Approvers) == 0 {
3✔
1219
                        // approval is automatic (strategy: auto) that is still on blocked
1✔
1220
                        return nil, fmt.Errorf("%w: can't modify approvers for approval with strategy auto", ErrUnableToDeleteApprover)
1✔
1221
                }
1✔
1222
        default:
1✔
1223
                return nil, fmt.Errorf("%w: can't delete approver to approval with %q status", ErrUnableToDeleteApprover, approval.Status)
1✔
1224
        }
1225

1226
        if len(approval.Approvers) == 1 {
5✔
1227
                return nil, fmt.Errorf("%w: can't delete if there's only one approver", ErrUnableToDeleteApprover)
1✔
1228
        }
1✔
1229

1230
        if err := s.approvalService.DeleteApprover(ctx, approvalID, email); err != nil {
4✔
1231
                return nil, err
1✔
1232
        }
1✔
1233

1234
        var newApprovers []string
2✔
1235
        for _, a := range approval.Approvers {
6✔
1236
                if a != email {
6✔
1237
                        newApprovers = append(newApprovers, a)
2✔
1238
                }
2✔
1239
        }
1240
        approval.Approvers = newApprovers
2✔
1241

2✔
1242
        auditData, err := utils.StructToMap(approval)
2✔
1243
        if err != nil {
2✔
UNCOV
1244
                return nil, fmt.Errorf("converting approval to map: %w", err)
×
UNCOV
1245
        }
×
1246
        auditData["affected_approver"] = email
2✔
1247
        go func() {
4✔
1248
                ctx := context.WithoutCancel(ctx)
2✔
1249
                if err := s.auditLogger.Log(ctx, AuditKeyDeleteApprover, auditData); err != nil {
2✔
UNCOV
1250
                        s.logger.Error(ctx, "failed to record audit log", "error", err)
×
UNCOV
1251
                }
×
1252
        }()
1253

1254
        return appeal, nil
2✔
1255
}
1256

1257
func (s *Service) getApproval(ctx context.Context, appealID, approvalID string) (*domain.Appeal, *domain.Approval, error) {
23✔
1258
        if appealID == "" {
25✔
1259
                return nil, nil, ErrAppealIDEmptyParam
2✔
1260
        }
2✔
1261
        if approvalID == "" {
23✔
1262
                return nil, nil, ErrApprovalIDEmptyParam
2✔
1263
        }
2✔
1264

1265
        appeal, err := s.repo.GetByID(ctx, appealID)
19✔
1266
        if err != nil {
21✔
1267
                return nil, nil, fmt.Errorf("getting appeal details: %w", err)
2✔
1268
        }
2✔
1269

1270
        approval := appeal.GetApproval(approvalID)
17✔
1271
        if approval == nil {
18✔
1272
                return nil, nil, ErrApprovalNotFound
1✔
1273
        }
1✔
1274

1275
        return appeal, approval, nil
16✔
1276
}
1277

1278
// getAppealsMap returns map[account_id]map[resource_id]map[role]*domain.Appeal, error
1279
func (s *Service) getAppealsMap(ctx context.Context, filters *domain.ListAppealsFilter) (map[string]map[string]map[string]*domain.Appeal, error) {
56✔
1280
        appeals, err := s.repo.Find(ctx, filters)
56✔
1281
        if err != nil {
58✔
1282
                return nil, err
2✔
1283
        }
2✔
1284

1285
        appealsMap := map[string]map[string]map[string]*domain.Appeal{}
54✔
1286
        for _, a := range appeals {
56✔
1287
                accountID := strings.ToLower(a.AccountID)
2✔
1288
                if appealsMap[accountID] == nil {
4✔
1289
                        appealsMap[accountID] = map[string]map[string]*domain.Appeal{}
2✔
1290
                }
2✔
1291
                if appealsMap[accountID][a.ResourceID] == nil {
4✔
1292
                        appealsMap[accountID][a.ResourceID] = map[string]*domain.Appeal{}
2✔
1293
                }
2✔
1294
                appealsMap[accountID][a.ResourceID][a.Role] = a
2✔
1295
        }
1296

1297
        return appealsMap, nil
54✔
1298
}
1299

1300
func (s *Service) getResourcesMap(ctx context.Context, ids []string) (map[string]*domain.Resource, error) {
32✔
1301
        filters := domain.ListResourcesFilter{IDs: ids}
32✔
1302
        resources, err := s.resourceService.Find(ctx, filters)
32✔
1303
        if err != nil {
33✔
1304
                return nil, err
1✔
1305
        }
1✔
1306

1307
        result := map[string]*domain.Resource{}
31✔
1308
        for _, r := range resources {
63✔
1309
                result[r.ID] = r
32✔
1310
        }
32✔
1311

1312
        return result, nil
31✔
1313
}
1314

1315
func (s *Service) getProvidersMap(ctx context.Context) (map[string]map[string]*domain.Provider, error) {
56✔
1316
        providers, err := s.providerService.Find(ctx)
56✔
1317
        if err != nil {
58✔
1318
                return nil, err
2✔
1319
        }
2✔
1320

1321
        providersMap := map[string]map[string]*domain.Provider{}
54✔
1322
        for _, p := range providers {
99✔
1323
                providerType := p.Type
45✔
1324
                providerURN := p.URN
45✔
1325
                if providersMap[providerType] == nil {
90✔
1326
                        providersMap[providerType] = map[string]*domain.Provider{}
45✔
1327
                }
45✔
1328
                if providersMap[providerType][providerURN] == nil {
90✔
1329
                        providersMap[providerType][providerURN] = p
45✔
1330
                }
45✔
1331
        }
1332

1333
        return providersMap, nil
54✔
1334
}
1335

1336
func (s *Service) getPoliciesMap(ctx context.Context) (map[string]map[uint]*domain.Policy, error) {
56✔
1337
        policies, err := s.policyService.Find(ctx)
56✔
1338
        if err != nil {
58✔
1339
                return nil, err
2✔
1340
        }
2✔
1341

1342
        policiesMap := map[string]map[uint]*domain.Policy{}
54✔
1343
        for _, p := range policies {
106✔
1344
                id := p.ID
52✔
1345
                if policiesMap[id] == nil {
101✔
1346
                        policiesMap[id] = map[uint]*domain.Policy{}
49✔
1347
                }
49✔
1348
                policiesMap[id][p.Version] = p
52✔
1349
                // set policiesMap[id][0] to latest policy version
52✔
1350
                if policiesMap[id][0] == nil || p.Version > policiesMap[id][0].Version {
102✔
1351
                        policiesMap[id][0] = p
50✔
1352
                }
50✔
1353
        }
1354

1355
        return policiesMap, nil
54✔
1356
}
1357

1358
func (s *Service) getApprovalNotifications(ctx context.Context, appeal *domain.Appeal) []domain.Notification {
20✔
1359
        notifications := []domain.Notification{}
20✔
1360
        approval := appeal.GetNextPendingApproval()
20✔
1361

20✔
1362
        duration := domain.PermanentDurationLabel
20✔
1363
        var err error
20✔
1364
        if !appeal.IsDurationEmpty() {
25✔
1365
                duration, err = utils.GetReadableDuration(appeal.Options.Duration)
5✔
1366
                if err != nil {
5✔
UNCOV
1367
                        s.logger.Error(ctx, "failed to get readable duration", "error", err, "appeal_id", appeal.ID)
×
UNCOV
1368
                }
×
1369
        }
1370

1371
        if approval != nil {
36✔
1372
                for _, approver := range approval.Approvers {
33✔
1373
                        notifications = append(notifications, domain.Notification{
17✔
1374
                                User: approver,
17✔
1375
                                Labels: map[string]string{
17✔
1376
                                        "appeal_id": appeal.ID,
17✔
1377
                                },
17✔
1378
                                Message: domain.NotificationMessage{
17✔
1379
                                        Type: domain.NotificationTypeApproverNotification,
17✔
1380
                                        Variables: map[string]interface{}{
17✔
1381
                                                "resource_name": fmt.Sprintf("%s (%s: %s)", appeal.Resource.Name, appeal.Resource.ProviderType, appeal.Resource.URN),
17✔
1382
                                                "role":          appeal.Role,
17✔
1383
                                                "requestor":     appeal.CreatedBy,
17✔
1384
                                                "appeal_id":     appeal.ID,
17✔
1385
                                                "account_id":    appeal.AccountID,
17✔
1386
                                                "account_type":  appeal.AccountType,
17✔
1387
                                                "provider_type": appeal.Resource.ProviderType,
17✔
1388
                                                "resource_type": appeal.Resource.Type,
17✔
1389
                                                "created_at":    appeal.CreatedAt,
17✔
1390
                                                "approval_step": approval.Name,
17✔
1391
                                                "actor":         approver,
17✔
1392
                                                "details":       appeal.Details,
17✔
1393
                                                "duration":      duration,
17✔
1394
                                                "creator":       appeal.Creator,
17✔
1395
                                        },
17✔
1396
                                },
17✔
1397
                        })
17✔
1398
                }
17✔
1399
        }
1400
        return notifications
20✔
1401
}
1402

1403
func checkIfAppealStatusStillPending(status string) error {
21✔
1404
        switch status {
21✔
1405
        case domain.AppealStatusPending:
17✔
1406
                return nil
17✔
1407
        case
1408
                domain.AppealStatusCanceled,
1409
                domain.AppealStatusApproved,
1410
                domain.AppealStatusRejected:
3✔
1411
                return fmt.Errorf("%w: %q", ErrAppealNotEligibleForApproval, status)
3✔
1412
        default:
1✔
1413
                return fmt.Errorf("%w: %q", ErrAppealStatusUnrecognized, status)
1✔
1414
        }
1415
}
1416

1417
func checkPreviousApprovalStatus(status, name string) error {
13✔
1418
        switch status {
13✔
1419
        case
1420
                domain.ApprovalStatusApproved,
1421
                domain.ApprovalStatusSkipped:
10✔
1422
                return nil
10✔
1423
        case
1424
                domain.ApprovalStatusBlocked,
1425
                domain.ApprovalStatusPending,
1426
                domain.ApprovalStatusRejected:
2✔
1427
                return fmt.Errorf("%w: found previous approval %q with status %q", ErrApprovalNotEligibleForAction, name, status)
2✔
1428
        default:
1✔
1429
                return fmt.Errorf("%w: found previous approval %q with unrecognized status %q", ErrApprovalStatusUnrecognized, name, status)
1✔
1430
        }
1431
}
1432

1433
func checkApprovalStatus(status string) error {
13✔
1434
        switch status {
13✔
1435
        case domain.ApprovalStatusPending:
9✔
1436
                return nil
9✔
1437
        case
1438
                domain.ApprovalStatusBlocked,
1439
                domain.ApprovalStatusApproved,
1440
                domain.ApprovalStatusRejected,
1441
                domain.ApprovalStatusSkipped:
3✔
1442
                return fmt.Errorf("%w: approval status %q is not actionable", ErrApprovalNotEligibleForAction, status)
3✔
1443
        default:
1✔
1444
                return fmt.Errorf("%w: %q", ErrApprovalStatusUnrecognized, status)
1✔
1445
        }
1446
}
1447

1448
func (s *Service) handleAppealRequirements(ctx context.Context, a *domain.Appeal, p *domain.Policy) error {
7✔
1449
        if p.Requirements != nil && len(p.Requirements) > 0 {
9✔
1450
                g, ctx := errgroup.WithContext(ctx)
2✔
1451

2✔
1452
                for reqIndex, r := range p.Requirements {
5✔
1453
                        isAppealMatchesRequirement, err := r.On.IsMatch(a)
3✔
1454
                        if err != nil {
4✔
1455
                                return fmt.Errorf("evaluating requirements[%v]: %v", reqIndex, err)
1✔
1456
                        }
1✔
1457
                        if !isAppealMatchesRequirement {
3✔
1458
                                continue
1✔
1459
                        }
1460

1461
                        for _, aa := range r.Appeals {
2✔
1462
                                aa := aa // https://golang.org/doc/faq#closures_and_goroutines
1✔
1463
                                g.Go(func() error {
2✔
1464
                                        // TODO: populate resource data from policyService
1✔
1465
                                        resource, err := s.resourceService.Get(ctx, aa.Resource)
1✔
1466
                                        if err != nil {
1✔
1467
                                                return fmt.Errorf("retrieving resource: %v", err)
×
UNCOV
1468
                                        }
×
1469

1470
                                        additionalAppeal := &domain.Appeal{
1✔
1471
                                                AccountID:   a.AccountID,
1✔
1472
                                                AccountType: a.AccountType,
1✔
1473
                                                CreatedBy:   a.CreatedBy,
1✔
1474
                                                Role:        aa.Role,
1✔
1475
                                                ResourceID:  resource.ID,
1✔
1476
                                        }
1✔
1477
                                        if aa.Options != nil {
1✔
UNCOV
1478
                                                additionalAppeal.Options = aa.Options
×
UNCOV
1479
                                        }
×
1480
                                        if aa.Policy != nil {
1✔
UNCOV
1481
                                                additionalAppeal.PolicyID = aa.Policy.ID
×
UNCOV
1482
                                                additionalAppeal.PolicyVersion = uint(aa.Policy.Version)
×
UNCOV
1483
                                        }
×
1484
                                        if err := s.Create(ctx, []*domain.Appeal{additionalAppeal}, CreateWithAdditionalAppeal()); err != nil {
1✔
UNCOV
1485
                                                if errors.Is(err, ErrAppealDuplicate) {
×
UNCOV
1486
                                                        s.logger.Warn(ctx, "creating additional appeals, duplicate appeal error log", "error", err)
×
UNCOV
1487
                                                        return nil
×
UNCOV
1488
                                                }
×
UNCOV
1489
                                                return fmt.Errorf("creating additional appeals: %w", err)
×
1490
                                        }
1491
                                        return nil
1✔
1492
                                })
1493
                        }
1494
                }
1495
                if err := g.Wait(); err == nil {
2✔
1496
                        return err
1✔
1497
                }
1✔
1498
        }
1499
        return nil
5✔
1500
}
1501

1502
func (s *Service) GrantAccessToProvider(ctx context.Context, a *domain.Appeal, opts ...CreateAppealOption) error {
10✔
1503
        policy := a.Policy
10✔
1504
        if policy == nil {
18✔
1505
                p, err := s.policyService.GetOne(ctx, a.PolicyID, a.PolicyVersion)
8✔
1506
                if err != nil {
9✔
1507
                        return fmt.Errorf("retrieving policy: %w", err)
1✔
1508
                }
1✔
1509
                policy = p
7✔
1510
        }
1511

1512
        createAppealOpts := &createAppealOptions{}
9✔
1513
        for _, opt := range opts {
11✔
1514
                opt(createAppealOpts)
2✔
1515
        }
2✔
1516

1517
        isAdditionalAppealCreation := createAppealOpts.IsAdditionalAppeal
9✔
1518
        if !isAdditionalAppealCreation {
16✔
1519
                if err := s.handleAppealRequirements(ctx, a, policy); err != nil {
8✔
1520
                        return fmt.Errorf("handling appeal requirements: %w", err)
1✔
1521
                }
1✔
1522
        }
1523

1524
        appealCopy := *a
8✔
1525
        appealCopy.Grant = nil
8✔
1526
        grantWithAppeal := *a.Grant
8✔
1527
        grantWithAppeal.Appeal = &appealCopy
8✔
1528

8✔
1529
        // grant access dependencies (if any)
8✔
1530
        dependencyGrants, err := s.providerService.GetDependencyGrants(ctx, grantWithAppeal)
8✔
1531
        if err != nil {
8✔
1532
                return fmt.Errorf("getting grant dependencies: %w", err)
×
1533
        }
×
1534
        for _, dg := range dependencyGrants {
8✔
1535
                activeDepGrants, err := s.grantService.List(ctx, domain.ListGrantsFilter{
×
1536
                        Statuses:     []string{string(domain.GrantStatusActive)},
×
UNCOV
1537
                        AccountIDs:   []string{dg.AccountID},
×
UNCOV
1538
                        AccountTypes: []string{dg.AccountType},
×
1539
                        ResourceIDs:  []string{dg.Resource.ID},
×
1540
                        Permissions:  dg.Permissions,
×
1541
                        Size:         1,
×
1542
                })
×
1543
                if err != nil {
×
1544
                        return fmt.Errorf("failed to get existing active grant dependency: %w", err)
×
1545
                }
×
1546

1547
                if len(activeDepGrants) > 0 {
×
1548
                        continue
×
1549
                }
1550

UNCOV
1551
                dg.Status = domain.GrantStatusActive
×
UNCOV
1552
                dg.Appeal = &appealCopy
×
UNCOV
1553
                if err := s.providerService.GrantAccess(ctx, *dg); err != nil {
×
UNCOV
1554
                        return fmt.Errorf("failed to grant an access dependency: %w", err)
×
UNCOV
1555
                }
×
UNCOV
1556
                dg.Appeal = nil
×
UNCOV
1557

×
UNCOV
1558
                dg.Owner = a.CreatedBy
×
UNCOV
1559
                if err := s.grantService.Create(ctx, dg); err != nil {
×
UNCOV
1560
                        return fmt.Errorf("failed to store grant of access dependency: %w", err)
×
UNCOV
1561
                }
×
1562
        }
1563

1564
        // grant main access
1565
        if err := s.providerService.GrantAccess(ctx, grantWithAppeal); err != nil {
9✔
1566
                return fmt.Errorf("granting access: %w", err)
1✔
1567
        }
1✔
1568

1569
        grantWithAppeal.Appeal = nil
7✔
1570
        return nil
7✔
1571
}
1572

1573
func (s *Service) checkExtensionEligibility(a *domain.Appeal, p *domain.Provider, policy *domain.Policy, activeGrant *domain.Grant) error {
23✔
1574
        allowActiveAccessExtensionIn := ""
23✔
1575

23✔
1576
        // Default to use provider config if policy config is not set
23✔
1577
        if p.Config.Appeal != nil {
46✔
1578
                allowActiveAccessExtensionIn = p.Config.Appeal.AllowActiveAccessExtensionIn
23✔
1579
        }
23✔
1580

1581
        // Use policy config if set
1582
        if policy != nil &&
23✔
1583
                policy.AppealConfig != nil &&
23✔
1584
                policy.AppealConfig.AllowActiveAccessExtensionIn != "" {
23✔
UNCOV
1585
                allowActiveAccessExtensionIn = policy.AppealConfig.AllowActiveAccessExtensionIn
×
UNCOV
1586
        }
×
1587

1588
        if allowActiveAccessExtensionIn == "" {
25✔
1589
                return ErrAppealFoundActiveGrant
2✔
1590
        }
2✔
1591

1592
        extensionDurationRule, err := time.ParseDuration(allowActiveAccessExtensionIn)
21✔
1593
        if err != nil {
23✔
1594
                return fmt.Errorf("%w: %q: %v", ErrAppealInvalidExtensionDuration, allowActiveAccessExtensionIn, err)
2✔
1595
        }
2✔
1596

1597
        if !activeGrant.IsEligibleForExtension(extensionDurationRule) {
21✔
1598
                return fmt.Errorf("%w: extension is allowed %q before grant expiration", ErrGrantNotEligibleForExtension, allowActiveAccessExtensionIn)
2✔
1599
        }
2✔
1600
        return nil
17✔
1601
}
1602

1603
func getPolicy(a *domain.Appeal, p *domain.Provider, policiesMap map[string]map[uint]*domain.Policy) (*domain.Policy, error) {
42✔
1604
        var policyConfig domain.PolicyConfig
42✔
1605
        var resourceConfig *domain.ResourceConfig
42✔
1606
        for _, rc := range p.Config.Resources {
88✔
1607
                if rc.Type == a.Resource.Type {
86✔
1608
                        resourceConfig = rc
40✔
1609
                        break
40✔
1610
                }
1611
        }
1612
        if resourceConfig == nil {
44✔
1613
                return nil, fmt.Errorf("%w: couldn't find %q resource type in the provider config", ErrInvalidResourceType, a.Resource.Type)
2✔
1614
        }
2✔
1615
        policyConfig = *resourceConfig.Policy
40✔
1616

40✔
1617
        appealMap, err := a.ToMap()
40✔
1618
        if err != nil {
40✔
UNCOV
1619
                return nil, fmt.Errorf("parsing appeal struct to map: %w", err)
×
UNCOV
1620
        }
×
1621

1622
        var dynamicPolicyConfigData string
40✔
1623
        for _, pc := range p.Config.Policies {
43✔
1624
                if pc.When != "" {
6✔
1625
                        v, err := evaluator.Expression(pc.When).EvaluateWithVars(map[string]interface{}{
3✔
1626
                                "appeal": appealMap,
3✔
1627
                        })
3✔
1628
                        if err != nil {
3✔
UNCOV
1629
                                return nil, err
×
UNCOV
1630
                        }
×
1631

1632
                        isFalsy := reflect.ValueOf(v).IsZero()
3✔
1633
                        if isFalsy {
5✔
1634
                                continue
2✔
1635
                        }
1636

1637
                        dynamicPolicyConfigData = pc.Policy
1✔
1638
                        break
1✔
1639
                }
1640
        }
1641

1642
        if dynamicPolicyConfigData != "" {
41✔
1643
                var dynamicPolicyConfig domain.PolicyConfig
1✔
1644
                policyData := strings.Split(dynamicPolicyConfigData, "@")
1✔
1645
                dynamicPolicyConfig.ID = policyData[0]
1✔
1646
                if len(policyData) > 1 {
2✔
1647
                        var version int
1✔
1648
                        if policyData[1] == "latest" {
2✔
1649
                                version = 0
1✔
1650
                        } else {
1✔
UNCOV
1651
                                version, err = strconv.Atoi(policyData[1])
×
UNCOV
1652
                        }
×
1653
                        if err != nil {
1✔
UNCOV
1654
                                return nil, fmt.Errorf("invalid policy version: %w", err)
×
UNCOV
1655
                        }
×
1656
                        dynamicPolicyConfig.Version = version
1✔
1657
                }
1658
                policyConfig = dynamicPolicyConfig
1✔
1659
        }
1660

1661
        policy, ok := policiesMap[policyConfig.ID][uint(policyConfig.Version)]
40✔
1662
        if !ok {
44✔
1663
                return nil, fmt.Errorf("couldn't find details for policy %q: %w", fmt.Sprintf("%s@%v", policyConfig.ID, policyConfig.Version), ErrPolicyNotFound)
4✔
1664
        }
4✔
1665
        return policy, nil
36✔
1666
}
1667

1668
func (s *Service) populateAppealMetadata(ctx context.Context, a *domain.Appeal, p *domain.Policy) error {
21✔
1669
        if !p.HasAppealMetadataSources() {
38✔
1670
                return nil
17✔
1671
        }
17✔
1672

1673
        eg, egctx := errgroup.WithContext(ctx)
4✔
1674
        var mu sync.Mutex
4✔
1675
        appealMetadata := map[string]interface{}{}
4✔
1676
        for key, metadata := range p.AppealConfig.MetadataSources {
8✔
1677
                key, metadata := key, metadata
4✔
1678
                eg.Go(func() error {
8✔
1679
                        switch metadata.Type {
4✔
1680
                        case "http":
4✔
1681
                                var cfg policy.AppealMetadataSourceConfigHTTP
4✔
1682
                                if err := mapstructure.Decode(metadata.Config, &cfg); err != nil {
4✔
UNCOV
1683
                                        return fmt.Errorf("error decoding metadata config: %w", err)
×
UNCOV
1684
                                }
×
1685

1686
                                if cfg.URL == "" {
4✔
1687
                                        return fmt.Errorf("URL cannot be empty for http type")
×
UNCOV
1688
                                }
×
1689

1690
                                var err error
4✔
1691
                                cfg.URL, err = evaluateExpressionWithAppeal(a, cfg.URL)
4✔
1692
                                if err != nil {
5✔
1693
                                        return err
1✔
1694
                                }
1✔
1695

1696
                                cfg.Body, err = evaluateExpressionWithAppeal(a, cfg.Body)
3✔
1697
                                if err != nil {
3✔
1698
                                        return err
×
1699
                                }
×
1700

1701
                                clientCreator := &http.HttpClientCreatorStruct{}
3✔
1702
                                metadataCl, err := http.NewHTTPClient(&cfg.HTTPClientConfig, clientCreator, "AppealMetadata")
3✔
1703
                                if err != nil {
3✔
UNCOV
1704
                                        return fmt.Errorf("key: %s, %w", key, err)
×
1705
                                }
×
1706

1707
                                res, err := metadataCl.MakeRequest(egctx)
3✔
1708
                                if err != nil || (res.StatusCode < 200 || res.StatusCode > 300) {
3✔
UNCOV
1709
                                        if cfg.AllowFailed {
×
UNCOV
1710
                                                return nil
×
1711
                                        }
×
1712
                                        return fmt.Errorf("error fetching resource: %w", err)
×
1713
                                }
1714

1715
                                body, err := io.ReadAll(res.Body)
3✔
1716
                                if err != nil {
3✔
UNCOV
1717
                                        return fmt.Errorf("error reading response body: %w", err)
×
UNCOV
1718
                                }
×
1719
                                defer res.Body.Close()
3✔
1720
                                var jsonBody interface{}
3✔
1721
                                err = json.Unmarshal(body, &jsonBody)
3✔
1722
                                if err != nil {
3✔
UNCOV
1723
                                        return fmt.Errorf("error unmarshaling response body: %w", err)
×
UNCOV
1724
                                }
×
1725

1726
                                responseMap := map[string]interface{}{
3✔
1727
                                        "status":      res.Status,
3✔
1728
                                        "status_code": res.StatusCode,
3✔
1729
                                        "headers":     res.Header,
3✔
1730
                                        "body":        jsonBody,
3✔
1731
                                }
3✔
1732
                                params := map[string]interface{}{
3✔
1733
                                        "response": responseMap,
3✔
1734
                                        "appeal":   a,
3✔
1735
                                }
3✔
1736

3✔
1737
                                value, err := metadata.EvaluateValue(params)
3✔
1738
                                if err != nil {
3✔
1739
                                        return fmt.Errorf("error parsing value: %w", err)
×
1740
                                }
×
1741
                                mu.Lock()
3✔
1742
                                appealMetadata[key] = value
3✔
1743
                                mu.Unlock()
3✔
UNCOV
1744
                        case "static":
×
UNCOV
1745
                                params := map[string]interface{}{"appeal": a}
×
UNCOV
1746
                                value, err := metadata.EvaluateValue(params)
×
UNCOV
1747
                                if err != nil {
×
UNCOV
1748
                                        return fmt.Errorf("error parsing value: %w", err)
×
UNCOV
1749
                                }
×
UNCOV
1750
                                mu.Lock()
×
UNCOV
1751
                                appealMetadata[key] = value
×
UNCOV
1752
                                mu.Unlock()
×
UNCOV
1753
                        default:
×
UNCOV
1754
                                return fmt.Errorf("invalid metadata source type")
×
1755
                        }
1756

1757
                        return nil
3✔
1758
                })
1759
        }
1760

1761
        if err := eg.Wait(); err != nil {
5✔
1762
                return err
1✔
1763
        }
1✔
1764

1765
        if a.Details == nil {
6✔
1766
                a.Details = map[string]interface{}{}
3✔
1767
        }
3✔
1768
        a.Details[domain.ReservedDetailsKeyPolicyMetadata] = appealMetadata
3✔
1769

3✔
1770
        return nil
3✔
1771
}
1772

1773
func (s *Service) addCreatorDetails(ctx context.Context, a *domain.Appeal, p *domain.Policy) error {
21✔
1774
        if p.IAM == nil {
26✔
1775
                return nil
5✔
1776
        }
5✔
1777

1778
        iamConfig, err := s.iam.ParseConfig(p.IAM)
16✔
1779
        if err != nil {
16✔
UNCOV
1780
                return fmt.Errorf("parsing policy.iam config: %w", err)
×
1781
        }
×
1782
        iamClient, err := s.iam.GetClient(iamConfig)
16✔
1783
        if err != nil {
16✔
UNCOV
1784
                return fmt.Errorf("initializing iam client: %w", err)
×
UNCOV
1785
        }
×
1786

1787
        userDetails, err := iamClient.GetUser(a.CreatedBy)
16✔
1788
        if err != nil {
22✔
1789
                if p.AppealConfig != nil && p.AppealConfig.AllowCreatorDetailsFailure {
12✔
1790
                        s.logger.Warn(ctx, "unable to get creator details", "error", err)
6✔
1791
                        return nil
6✔
1792
                }
6✔
UNCOV
1793
                return fmt.Errorf("unable to get creator details: %w", err)
×
1794
        }
1795

1796
        userDetailsMap, ok := userDetails.(map[string]interface{})
10✔
1797
        if !ok {
10✔
UNCOV
1798
                return nil
×
UNCOV
1799
        }
×
1800

1801
        if p.IAM.Schema == nil {
11✔
1802
                a.Creator = userDetailsMap
1✔
1803
                return nil
1✔
1804
        }
1✔
1805

1806
        creator := map[string]interface{}{}
9✔
1807
        for schemaKey, targetKey := range p.IAM.Schema {
45✔
1808
                if strings.Contains(targetKey, "$response") {
54✔
1809
                        params := map[string]interface{}{
18✔
1810
                                "response": userDetailsMap,
18✔
1811
                        }
18✔
1812
                        v, err := evaluator.Expression(targetKey).EvaluateWithVars(params)
18✔
1813
                        if err != nil {
18✔
UNCOV
1814
                                return fmt.Errorf("evaluating expression: %w", err)
×
UNCOV
1815
                        }
×
1816
                        creator[schemaKey] = v
18✔
1817
                } else {
18✔
1818
                        creator[schemaKey] = userDetailsMap[targetKey]
18✔
1819
                }
18✔
1820
        }
1821

1822
        a.Creator = creator
9✔
1823
        s.logger.Debug(ctx, "added creator details", "creator", creator)
9✔
1824

9✔
1825
        return nil
9✔
1826
}
1827

1828
func addResource(a *domain.Appeal, resourcesMap map[string]*domain.Resource) error {
29✔
1829
        r := resourcesMap[a.ResourceID]
29✔
1830
        if r == nil {
30✔
1831
                return ErrResourceNotFound
1✔
1832
        } else if r.IsDeleted {
29✔
UNCOV
1833
                return ErrResourceDeleted
×
UNCOV
1834
        }
×
1835

1836
        a.Resource = r
28✔
1837
        return nil
28✔
1838
}
1839

1840
func getProvider(a *domain.Appeal, providersMap map[string]map[string]*domain.Provider) (*domain.Provider, error) {
47✔
1841
        provider, ok := providersMap[a.Resource.ProviderType][a.Resource.ProviderURN]
47✔
1842
        if !ok {
51✔
1843
                return nil, fmt.Errorf("couldn't find details for provider %q: %w", a.Resource.ProviderType+" - "+a.Resource.ProviderURN, ErrProviderNotFound)
4✔
1844
        }
4✔
1845
        return provider, nil
43✔
1846
}
1847

1848
func validateAppeal(a *domain.Appeal, pendingAppealsMap map[string]map[string]map[string]*domain.Appeal) error {
43✔
1849
        accountID := strings.ToLower(a.AccountID)
43✔
1850
        if pendingAppealsMap[accountID] != nil &&
43✔
1851
                pendingAppealsMap[accountID][a.ResourceID] != nil &&
43✔
1852
                pendingAppealsMap[accountID][a.ResourceID][a.Role] != nil {
45✔
1853
                return ErrAppealDuplicate
2✔
1854
        }
2✔
1855

1856
        return nil
41✔
1857
}
1858

1859
// isPackageRAMRole checks if the given account_id matches the naming pattern for package RAM roles
UNCOV
1860
func isPackageRAMRole(accountID string) bool {
×
UNCOV
1861
        return strings.Contains(accountID, ":role/Package-") ||
×
UNCOV
1862
                strings.Contains(accountID, "/Package-")
×
UNCOV
1863
}
×
1864

1865
// validatePackageRAMRoleUsage validates that package RAM roles are only used with proper group_id
1866
func (s *Service) validatePackageRAMRoleUsage(ctx context.Context, appeal *domain.Appeal) error {
30✔
1867
        // Only validate if account is a RAM role
30✔
1868
        if appeal.AccountType != "ram_role" {
60✔
1869
                return nil
30✔
1870
        }
30✔
1871

1872
        // Check if this looks like a package RAM role (naming convention)
UNCOV
1873
        if !isPackageRAMRole(appeal.AccountID) {
×
1874
                return nil // Not a package role, allow
×
1875
        }
×
1876

1877
        // If it's a package RAM role, group_id MUST be present
UNCOV
1878
        if appeal.GroupID == "" {
×
UNCOV
1879
                return ErrPackageRAMRoleRequiresGroupID
×
1880
        }
×
1881

1882
        // Validate group_type is "package"
UNCOV
1883
        if appeal.GroupType != "package" {
×
UNCOV
1884
                return ErrInvalidGroupType
×
UNCOV
1885
        }
×
1886

1887
        // Get package resource to verify RAM role matches
UNCOV
1888
        packageResource, err := s.resourceService.Get(ctx, &domain.ResourceIdentifier{ID: appeal.GroupID})
×
UNCOV
1889
        if err != nil {
×
UNCOV
1890
                return fmt.Errorf("failed to get package %q: %w", appeal.GroupID, err)
×
1891
        }
×
1892

1893
        // Extract RAM role from package resource
UNCOV
1894
        expectedRAMRole, ok := packageResource.Details["ram_role_arn"].(string)
×
UNCOV
1895
        if !ok || expectedRAMRole == "" {
×
1896
                return fmt.Errorf("package %q does not have a RAM role configured", appeal.GroupID)
×
1897
        }
×
1898

1899
        // Verify account_id matches the package's RAM role
UNCOV
1900
        if appeal.AccountID != expectedRAMRole {
×
UNCOV
1901
                return ErrPackageRAMRoleMismatch
×
UNCOV
1902
        }
×
1903

UNCOV
1904
        return nil
×
1905
}
1906

1907
func (s *Service) getPermissions(ctx context.Context, pc *domain.ProviderConfig, resourceType, role string) ([]string, error) {
25✔
1908
        permissions, err := s.providerService.GetPermissions(ctx, pc, resourceType, role)
25✔
1909
        if err != nil {
25✔
1910
                return nil, err
×
1911
        }
×
1912

1913
        if permissions == nil {
25✔
UNCOV
1914
                return nil, nil
×
UNCOV
1915
        }
×
1916

1917
        strPermissions := []string{}
25✔
1918
        for _, p := range permissions {
46✔
1919
                strPermissions = append(strPermissions, fmt.Sprintf("%s", p))
21✔
1920
        }
21✔
1921
        return strPermissions, nil
25✔
1922
}
1923

1924
// TODO(feature): add relation between new and revoked grant for traceability
1925
func (s *Service) prepareGrant(ctx context.Context, appeal *domain.Appeal) (newGrant *domain.Grant, deactivatedGrant *domain.Grant, err error) {
6✔
1926
        filter := domain.ListGrantsFilter{
6✔
1927
                AccountIDs:  []string{appeal.AccountID},
6✔
1928
                ResourceIDs: []string{appeal.ResourceID},
6✔
1929
                Statuses:    []string{string(domain.GrantStatusActive)},
6✔
1930
                Permissions: appeal.Permissions,
6✔
1931
        }
6✔
1932
        revocationReason := RevokeReasonForExtension
6✔
1933
        if s.providerService.IsExclusiveRoleAssignment(ctx, appeal.Resource.ProviderType, appeal.Resource.Type) {
6✔
UNCOV
1934
                filter.Permissions = nil
×
UNCOV
1935
                revocationReason = RevokeReasonForOverride
×
UNCOV
1936
        }
×
1937

1938
        activeGrants, err := s.grantService.List(ctx, filter)
6✔
1939
        if err != nil {
6✔
UNCOV
1940
                return nil, nil, fmt.Errorf("unable to retrieve existing active grants: %w", err)
×
UNCOV
1941
        }
×
1942

1943
        if len(activeGrants) > 0 {
8✔
1944
                deactivatedGrant = &activeGrants[0]
2✔
1945
                if err := deactivatedGrant.Revoke(domain.SystemActorName, revocationReason); err != nil {
2✔
UNCOV
1946
                        return nil, nil, fmt.Errorf("revoking previous grant: %w", err)
×
UNCOV
1947
                }
×
1948
        }
1949

1950
        if err := appeal.Approve(); err != nil {
6✔
UNCOV
1951
                return nil, nil, fmt.Errorf("activating appeal: %w", err)
×
UNCOV
1952
        }
×
1953

1954
        grant, err := s.grantService.Prepare(ctx, *appeal)
6✔
1955
        if err != nil {
6✔
UNCOV
1956
                return nil, nil, err
×
UNCOV
1957
        }
×
1958

1959
        return grant, deactivatedGrant, nil
6✔
1960
}
1961

1962
func (s *Service) GetAppealsTotalCount(ctx context.Context, filters *domain.ListAppealsFilter) (int64, error) {
2✔
1963
        return s.repo.GetAppealsTotalCount(ctx, filters)
2✔
1964
}
2✔
1965

1966
func evaluateExpressionWithAppeal(a *domain.Appeal, expression string) (string, error) {
7✔
1967
        if expression != "" && strings.Contains(expression, "$appeal") {
11✔
1968
                appealMap, err := a.ToMap()
4✔
1969
                if err != nil {
4✔
UNCOV
1970
                        return "", fmt.Errorf("error converting appeal to map: %w", err)
×
UNCOV
1971
                }
×
1972
                params := map[string]interface{}{"appeal": appealMap}
4✔
1973
                evaluated, err := evaluator.Expression(expression).EvaluateWithVars(params)
4✔
1974
                if err != nil {
5✔
1975
                        return "", fmt.Errorf("error evaluating expression %w", err)
1✔
1976
                }
1✔
1977
                evaluatedStr, ok := evaluated.(string)
3✔
1978
                if !ok {
3✔
UNCOV
1979
                        return "", fmt.Errorf("expression must evaluate to a string")
×
UNCOV
1980
                }
×
1981
                return evaluatedStr, nil
3✔
1982
        }
1983
        return expression, nil
3✔
1984
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc