Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions integrations/access/accessmonitoring/access_monitoring_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ func (amrh *RuleHandler) RecipientsFromAccessMonitoringRules(ctx context.Context
}

for _, rule := range amrh.getAccessMonitoringRules() {
// Check if creation time is within rule schedules.
isInSchedules, err := accessmonitoring.InSchedules(rule.GetSpec().GetSchedules(), env.CreationTime)
if err != nil {
log.WarnContext(ctx, "Failed to evaluate access monitoring rule",
"error", err,
"rule", rule.GetMetadata().GetName(),
)
continue
}
if len(rule.GetSpec().GetSchedules()) != 0 && !isInSchedules {
log.DebugContext(ctx, "Access request does not satisfy schedule condition",
"rule", rule.GetMetadata().GetName())
continue
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and below, should we log that we're skipping the request because the schedule doesn't match?

}

// Check if environment matches rule conditions.
match, err := accessmonitoring.EvaluateCondition(rule.Spec.Condition, env)
if err != nil {
log.WarnContext(ctx, "Failed to parse access monitoring notification rule",
Expand Down Expand Up @@ -188,6 +204,22 @@ func (amrh *RuleHandler) RawRecipientsFromAccessMonitoringRules(ctx context.Cont
}

for _, rule := range amrh.getAccessMonitoringRules() {
// Check if creation time is within rule schedules.
isInSchedules, err := accessmonitoring.InSchedules(rule.GetSpec().GetSchedules(), env.CreationTime)
if err != nil {
log.WarnContext(ctx, "Failed to evaluate access monitoring rule",
"error", err,
"rule", rule.GetMetadata().GetName(),
)
continue
}
if len(rule.GetSpec().GetSchedules()) != 0 && !isInSchedules {
log.DebugContext(ctx, "Access request does not satisfy schedule condition",
"rule", rule.GetMetadata().GetName())
continue
}

// Check if environment matches rule conditions.
match, err := accessmonitoring.EvaluateCondition(rule.Spec.Condition, env)
if err != nil {
log.WarnContext(ctx, "Failed to parse access monitoring notification rule",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package accessmonitoring
import (
"context"
"testing"
"time"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -52,7 +53,7 @@ func mockFetchRecipient(ctx context.Context, recipient string) (*common.Recipien
return nil, nil
}

func TestRecipeints(t *testing.T) {
func TestRecipients(t *testing.T) {
const (
pluginName = "fakePluginName"
pluginType = "fakePluginType"
Expand Down Expand Up @@ -133,7 +134,7 @@ func TestRecipeints(t *testing.T) {
require.ElementsMatch(t, []string{}, rawRecipients)
}

func TestRecipeintsWithResources(t *testing.T) {
func TestRecipientsWithResources(t *testing.T) {
const (
pluginName = "fakePluginName"
pluginType = "fakePluginType"
Expand Down Expand Up @@ -205,6 +206,85 @@ func TestRecipeintsWithResources(t *testing.T) {
require.ElementsMatch(t, []string{recipient}, rawRecipients)
}

func TestRecipientsWithSchedules(t *testing.T) {
const (
pluginName = "fakePluginName"
pluginType = "fakePluginType"
recipient = "[email protected]"
)

teleportClient := &mockTeleportClient{}
teleportClient.
On("GetUser", mock.Anything, mock.Anything, mock.Anything).
Return(&types.UserV2{}, nil)

amrh := NewRuleHandler(RuleHandlerConfig{
Client: teleportClient,
PluginType: pluginType,
PluginName: pluginName,
FetchRecipientCallback: func(ctx context.Context, recipient string) (*common.Recipient, error) {
return emailRecipient(recipient), nil
},
})

rule1, err := services.NewAccessMonitoringRuleWithLabels("rule1", nil, &pb.AccessMonitoringRuleSpec{
Subjects: []string{types.KindAccessRequest},
Schedules: map[string]*pb.Schedule{
"default": {
Time: &pb.TimeSchedule{
Shifts: []*pb.TimeSchedule_Shift{
{
Weekday: time.Monday.String(),
Start: "14:00",
End: "15:00",
},
},
},
},
},
Condition: `true`,
Notification: &pb.Notification{
Name: pluginName,
Recipients: []string{recipient},
},
})
require.NoError(t, err)
err = amrh.HandleAccessMonitoringRule(context.Background(), types.Event{
Type: types.OpPut,
Resource: types.Resource153ToLegacy(rule1),
})
require.NoError(t, err)
require.Len(t, amrh.getAccessMonitoringRules(), 1)

ctx := context.Background()

// Expect recipient from matching rule.
req := &types.AccessRequestV3{
Spec: types.AccessRequestSpecV3{
Created: time.Date(2025, time.August, 11, 14, 30, 0, 0, time.UTC),
},
}

recipients := amrh.RecipientsFromAccessMonitoringRules(ctx, req)
require.ElementsMatch(t, []common.Recipient{*emailRecipient(recipient)}, recipients.ToSlice())

rawRecipients := amrh.RawRecipientsFromAccessMonitoringRules(ctx, req)
require.ElementsMatch(t, []string{recipient}, rawRecipients)

// Expect no recipient when not in schedule.
req = &types.AccessRequestV3{
Spec: types.AccessRequestSpecV3{
Created: time.Date(2025, time.August, 11, 15, 30, 0, 0, time.UTC),
},
}

recipients = amrh.RecipientsFromAccessMonitoringRules(ctx, req)
require.ElementsMatch(t, []common.Recipient{}, recipients.ToSlice())

rawRecipients = amrh.RawRecipientsFromAccessMonitoringRules(ctx, req)
require.ElementsMatch(t, []string{}, rawRecipients)
}

func emailRecipient(recipient string) *common.Recipient {
return &common.Recipient{
Name: recipient,
Expand Down
18 changes: 17 additions & 1 deletion lib/accessmonitoring/review/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,15 +241,31 @@ func (handler *Handler) getMatchingRule(
var reviewRule *accessmonitoringrulesv1.AccessMonitoringRule

for _, rule := range handler.rules.Get() {
conditionMatch, err := accessmonitoring.EvaluateCondition(rule.GetSpec().GetCondition(), env)

// Check if creation time is within rule schedules.
isInSchedules, err := accessmonitoring.InSchedules(rule.GetSpec().GetSchedules(), env.CreationTime)
if err != nil {
handler.Logger.WarnContext(ctx, "Failed to evaluate access monitoring rule",
"error", err,
"rule", rule.GetMetadata().GetName(),
)
continue
}
if len(rule.GetSpec().GetSchedules()) != 0 && !isInSchedules {
handler.Logger.DebugContext(ctx, "Access request does not satisfy schedule condition",
"rule", rule.GetMetadata().GetName())
continue
}

// Check if environment matches rule conditions.
conditionMatch, err := accessmonitoring.EvaluateCondition(rule.GetSpec().GetCondition(), env)
if err != nil {
handler.Logger.WarnContext(ctx, "Failed to evaluate access monitoring rule",
"error", err,
"rule", rule.GetMetadata().GetName(),
)
continue
}
if !conditionMatch {
continue
}
Expand Down
107 changes: 107 additions & 0 deletions lib/accessmonitoring/review/review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,113 @@ func TestConflictingRules(t *testing.T) {
require.NoError(t, handler.HandleAccessRequest(ctx, event))
}

func TestScheduleRequest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
t.Cleanup(cancel)

testReqID := uuid.New().String()
testRuleName := "test-rule"
withSecretsFalse := false
requesterUserName := "requester"

requester, err := types.NewUser(requesterUserName)
require.NoError(t, err)

testRule := newApprovedRule(
testRuleName,
`true`)

testRule.Spec.Schedules = map[string]*accessmonitoringrulesv1.Schedule{
"test-schedule": {
Time: &accessmonitoringrulesv1.TimeSchedule{
Shifts: []*accessmonitoringrulesv1.TimeSchedule_Shift{
{
Weekday: time.Monday.String(),
Start: "14:00",
End: "15:00",
},
},
},
},
}

cache := accessmonitoring.NewCache()
cache.Put([]*accessmonitoringrulesv1.AccessMonitoringRule{testRule})

tests := []struct {
description string
setupMock func(m *mockClient)
creationTime time.Time
assertErr require.ErrorAssertionFunc
}{
{
description: "test within schedule",
setupMock: func(m *mockClient) {
m.On("GetUser", mock.Anything, requesterUserName, withSecretsFalse).
Return(requester, nil)

review, err := newAccessReview(
requesterUserName,
testRuleName,
types.RequestState_APPROVED.String(),
time.Time{},
)
require.NoError(t, err)

m.On("SubmitAccessReview", mock.Anything, types.AccessReviewSubmission{
RequestID: testReqID,
Review: review,
}).Return(mock.Anything, nil)
},
creationTime: time.Date(2025, time.August, 11, 14, 30, 0, 0, time.UTC),
assertErr: require.NoError,
},
{
description: "test outside schedule",
setupMock: func(m *mockClient) {
m.On("GetUser", mock.Anything, requesterUserName, withSecretsFalse).
Return(requester, nil)

m.AssertNotCalled(t, "SubmitAccessReview")
},
creationTime: time.Date(2025, time.August, 11, 15, 30, 0, 0, time.UTC),
assertErr: require.NoError,
},
}

for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
t.Parallel()

client := &mockClient{}
if test.setupMock != nil {
test.setupMock(client)
}

handler, err := NewHandler(Config{
HandlerName: handlerName,
Client: client,
Cache: cache,
})
require.NoError(t, err)

req, err := types.NewAccessRequest(
testReqID,
requesterUserName,
"role",
)
require.NoError(t, err)
req.SetCreationTime(test.creationTime)

test.assertErr(t, handler.HandleAccessRequest(ctx, types.Event{
Type: types.OpPut,
Resource: req,
}))
client.AssertExpectations(t)
})
}
}

func TestResourceRequest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
t.Cleanup(cancel)
Expand Down
93 changes: 93 additions & 0 deletions lib/accessmonitoring/schedule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
Copyright 2025 Gravitational, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package accessmonitoring

import (
"time"

"github.com/gravitational/trace"

accessmonitoringrulesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessmonitoringrules/v1"
)

// ClockTime returns a new time value overriding the hour and minute.
func ClockTime(timestamp time.Time, hourMinute string) (time.Time, error) {
const hourMinuteFormat = "15:04" // 24-hour HH:MM format

parsed, err := time.ParseInLocation(hourMinuteFormat, hourMinute, timestamp.Location())
if err != nil {
return time.Time{}, trace.Wrap(err)
}

return time.Date(timestamp.Year(), timestamp.Month(), timestamp.Day(),
parsed.Hour(), parsed.Minute(), 0, 0, timestamp.Location()), nil
}

// inSchedule returns true if the timestamp is within the schedule.
func inSchedule(schedule *accessmonitoringrulesv1.Schedule, timestamp time.Time) (bool, error) {
if schedule.GetTime() == nil {
return false, nil
}

if len(schedule.GetTime().GetShifts()) == 0 {
return false, nil
}

loc, err := time.LoadLocation(schedule.GetTime().GetTimezone())
if err != nil {
return false, trace.Wrap(err)
}

timestamp = timestamp.In(loc)
weekday := timestamp.Weekday().String()

for _, shift := range schedule.GetTime().GetShifts() {
if weekday != shift.Weekday {
continue
}

startTime, err := ClockTime(timestamp, shift.Start)
if err != nil {
return false, trace.Wrap(err, "invalid start time: %q", shift.Start)
}

endTime, err := ClockTime(timestamp, shift.End)
if err != nil {
return false, trace.Wrap(err, "invalid end time: %q", shift.End)
}

if !timestamp.Before(startTime) && !timestamp.After(endTime) {
return true, nil
}
}
return false, nil
}

// InSchedules returns true if the provided timestamp is within an of the provided
// schedules. Returns false if schedules is empty.
func InSchedules(schedules map[string]*accessmonitoringrulesv1.Schedule, timestamp time.Time) (bool, error) {
for _, schedule := range schedules {
isInSchedule, err := inSchedule(schedule, timestamp)
if err != nil {
return false, trace.Wrap(err)
}
if isInSchedule {
return true, nil
}
}
return false, nil
}
Loading
Loading