diff --git a/api/types/accesslist/convert/v1/member.go b/api/types/accesslist/convert/v1/member.go index b4dd495ea79c3..f624287dbb17e 100644 --- a/api/types/accesslist/convert/v1/member.go +++ b/api/types/accesslist/convert/v1/member.go @@ -30,7 +30,7 @@ type MemberOption func(*accesslist.AccessListMember) // FromMemberProto converts a v1 access list member into an internal access list member object. func FromMemberProto(msg *accesslistv1.Member, opts ...MemberOption) (*accesslist.AccessListMember, error) { if msg == nil { - return nil, trace.BadParameter("access list message is nil") + return nil, trace.BadParameter("access list member message is nil") } if msg.Spec == nil { diff --git a/api/types/accesslist/convert/v1/review.go b/api/types/accesslist/convert/v1/review.go new file mode 100644 index 0000000000000..fe046abd5c090 --- /dev/null +++ b/api/types/accesslist/convert/v1/review.go @@ -0,0 +1,113 @@ +/* +Copyright 2023 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 v1 + +import ( + "time" + + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + accesslistv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accesslist/v1" + "github.com/gravitational/teleport/api/types/accesslist" + headerv1 "github.com/gravitational/teleport/api/types/header/convert/v1" + traitv1 "github.com/gravitational/teleport/api/types/trait/convert/v1" +) + +// FromReviewProto converts a v1 access list review into an internal access list review object. +func FromReviewProto(msg *accesslistv1.Review) (*accesslist.Review, error) { + if msg == nil { + return nil, trace.BadParameter("access list review message is nil") + } + + if msg.Spec == nil { + return nil, trace.BadParameter("spec is missing") + } + + // Manually check for the presence of the time so that we can be sure that the review date is + // zero if the proto message's review date is nil. + var reviewDate time.Time + if msg.Spec.ReviewDate != nil { + reviewDate = msg.Spec.ReviewDate.AsTime() + } + + var reviewChanges accesslist.ReviewChanges + if msg.Spec.Changes != nil { + if msg.Spec.Changes.FrequencyChanged != nil { + reviewChanges.FrequencyChanged = msg.Spec.Changes.FrequencyChanged.AsDuration() + } + if msg.Spec.Changes.MembershipRequirementsChanged != nil { + reviewChanges.MembershipRequirementsChanged = &accesslist.Requires{ + Roles: msg.Spec.Changes.MembershipRequirementsChanged.Roles, + Traits: traitv1.FromProto(msg.Spec.Changes.MembershipRequirementsChanged.Traits), + } + } + reviewChanges.RemovedMembers = msg.Spec.Changes.RemovedMembers + } + + member, err := accesslist.NewReview(headerv1.FromMetadataProto(msg.Header.Metadata), accesslist.ReviewSpec{ + AccessList: msg.Spec.AccessList, + Reviewers: msg.Spec.Reviewers, + ReviewDate: reviewDate, + Notes: msg.Spec.Notes, + Changes: reviewChanges, + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return member, nil +} + +// ToReviewProto converts an internal access list review into a v1 access list review object. +func ToReviewProto(review *accesslist.Review) *accesslistv1.Review { + var reviewChanges *accesslistv1.ReviewChanges + if review.Spec.Changes.FrequencyChanged > 0 { + reviewChanges = &accesslistv1.ReviewChanges{ + FrequencyChanged: durationpb.New(review.Spec.Changes.FrequencyChanged), + } + } + if review.Spec.Changes.MembershipRequirementsChanged != nil { + if reviewChanges == nil { + reviewChanges = &accesslistv1.ReviewChanges{} + } + + reviewChanges.MembershipRequirementsChanged = &accesslistv1.AccessListRequires{ + Roles: review.Spec.Changes.MembershipRequirementsChanged.Roles, + Traits: traitv1.ToProto(review.Spec.Changes.MembershipRequirementsChanged.Traits), + } + } + if len(review.Spec.Changes.RemovedMembers) > 0 { + if reviewChanges == nil { + reviewChanges = &accesslistv1.ReviewChanges{} + } + + reviewChanges.RemovedMembers = review.Spec.Changes.RemovedMembers + } + + return &accesslistv1.Review{ + Header: headerv1.ToResourceHeaderProto(review.ResourceHeader), + Spec: &accesslistv1.ReviewSpec{ + AccessList: review.Spec.AccessList, + Reviewers: review.Spec.Reviewers, + ReviewDate: timestamppb.New(review.Spec.ReviewDate), + Notes: review.Spec.Notes, + Changes: reviewChanges, + }, + } +} diff --git a/api/types/accesslist/convert/v1/review_test.go b/api/types/accesslist/convert/v1/review_test.go new file mode 100644 index 0000000000000..c9f58bf0a94c9 --- /dev/null +++ b/api/types/accesslist/convert/v1/review_test.go @@ -0,0 +1,193 @@ +/* +Copyright 2023 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 v1 + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types/accesslist" + "github.com/gravitational/teleport/api/types/header" + "github.com/gravitational/teleport/api/types/trait" + traitv1 "github.com/gravitational/teleport/api/types/trait/convert/v1" +) + +func TestReviewRoundtrip(t *testing.T) { + t.Parallel() + + review := newAccessListReview(t, "access-list-review") + + converted, err := FromReviewProto(ToReviewProto(review)) + require.NoError(t, err) + + require.Empty(t, cmp.Diff(review, converted)) +} + +// Make sure that we don't panic if any of the message fields are missing. +func TestReviewFromProtoNils(t *testing.T) { + t.Parallel() + + // Message is nil + _, err := FromReviewProto(nil) + require.Error(t, err) + + // Spec is nil + review := ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec = nil + + _, err = FromReviewProto(review) + require.Error(t, err) + + // AccessList is empty + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.AccessList = "" + + _, err = FromReviewProto(review) + require.Error(t, err) + + // Reviewers is empty + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.Reviewers = nil + + _, err = FromReviewProto(review) + require.Error(t, err) + + // ReviewDate is nil + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.ReviewDate = nil + + _, err = FromReviewProto(review) + require.Error(t, err) + + // Notes is empty + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.Notes = "" + + _, err = FromReviewProto(review) + require.NoError(t, err) + + // Changes is nil + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.Changes = nil + + _, err = FromReviewProto(review) + require.NoError(t, err) + + // FrequencyChanged is nil + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.Changes.FrequencyChanged = nil + + _, err = FromReviewProto(review) + require.NoError(t, err) + + // MembershipRequirementsChanged is nil + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.Changes.MembershipRequirementsChanged = nil + + _, err = FromReviewProto(review) + require.NoError(t, err) + + // RemovedMembers is nil + review = ToReviewProto(newAccessListReview(t, "access-list-review")) + review.Spec.Changes.RemovedMembers = nil + + _, err = FromReviewProto(review) + require.NoError(t, err) +} + +func TestReviewToProtoChanges(t *testing.T) { + t.Parallel() + + // No changes. + review := newAccessListReview(t, "access-list-review") + review.Spec.Changes.FrequencyChanged = 0 + review.Spec.Changes.MembershipRequirementsChanged = nil + review.Spec.Changes.RemovedMembers = nil + + msg := ToReviewProto(review) + require.Nil(t, msg.Spec.Changes) + + // Only frequency changes. + review = newAccessListReview(t, "access-list-review") + review.Spec.Changes.MembershipRequirementsChanged = nil + review.Spec.Changes.RemovedMembers = nil + + msg = ToReviewProto(review) + require.Equal(t, review.Spec.Changes.FrequencyChanged, msg.Spec.Changes.FrequencyChanged.AsDuration()) + require.Nil(t, msg.Spec.Changes.MembershipRequirementsChanged) + require.Nil(t, msg.Spec.Changes.RemovedMembers) + + // Only membership requires changes. + review = newAccessListReview(t, "access-list-review") + review.Spec.Changes.FrequencyChanged = 0 + review.Spec.Changes.RemovedMembers = nil + + msg = ToReviewProto(review) + require.Equal(t, time.Duration(0), review.Spec.Changes.FrequencyChanged) + require.Equal(t, review.Spec.Changes.MembershipRequirementsChanged.Roles, msg.Spec.Changes.MembershipRequirementsChanged.Roles) + require.Equal(t, review.Spec.Changes.MembershipRequirementsChanged.Traits, traitv1.FromProto(msg.Spec.Changes.MembershipRequirementsChanged.Traits)) + require.Nil(t, msg.Spec.Changes.RemovedMembers) + + // Only removed members changes. + review = newAccessListReview(t, "access-list-review") + review.Spec.Changes.FrequencyChanged = 0 + review.Spec.Changes.MembershipRequirementsChanged = nil + + msg = ToReviewProto(review) + require.Equal(t, time.Duration(0), review.Spec.Changes.FrequencyChanged) + require.Nil(t, msg.Spec.Changes.MembershipRequirementsChanged) + require.Equal(t, review.Spec.Changes.RemovedMembers, msg.Spec.Changes.RemovedMembers) +} + +func newAccessListReview(t *testing.T, name string) *accesslist.Review { + t.Helper() + + accessList, err := accesslist.NewReview( + header.Metadata{ + Name: name, + }, + accesslist.ReviewSpec{ + AccessList: "access-list", + Reviewers: []string{ + "reviewer1", + "reviewer2", + }, + ReviewDate: time.Date(2023, 01, 01, 0, 0, 0, 0, time.UTC), + Notes: "some notes", + Changes: accesslist.ReviewChanges{ + FrequencyChanged: 20 * time.Hour, + MembershipRequirementsChanged: &accesslist.Requires{ + Roles: []string{"role1", "role2"}, + Traits: trait.Traits{ + "trait1": []string{"value1"}, + "trait2": []string{"value2"}, + }, + }, + RemovedMembers: []string{ + "removed1", + "removed2", + "removed3", + }, + }, + }, + ) + require.NoError(t, err) + return accessList +} diff --git a/api/types/accesslist/review.go b/api/types/accesslist/review.go new file mode 100644 index 0000000000000..47f5f8ba9df99 --- /dev/null +++ b/api/types/accesslist/review.go @@ -0,0 +1,174 @@ +/* +Copyright 2023 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 accesslist + +import ( + "encoding/json" + "time" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/header" + "github.com/gravitational/teleport/api/types/header/convert/legacy" +) + +// Review is an access list review resource. +type Review struct { + // ResourceHeader is the common resource header for all resources. + header.ResourceHeader + + // Spec is the specification for the access list review. + Spec ReviewSpec `json:"spec" yaml:"spec"` +} + +// ReviewSpec describes the specification of a review of an access list. +type ReviewSpec struct { + // AccessList is the name of the associated access list. + AccessList string `json:"access_list" yaml:"access_list"` + + // Reviewers are the users who performed the review. + Reviewers []string `json:"reviewers" yaml:"reviewers"` + + // ReviewDate is the date that this review was created. + ReviewDate time.Time `json:"review_date" yaml:"review_date"` + + // Notes is an optional plaintext attached to the review that can be used by the review for arbitrary + // note taking on the review. + Notes string `json:"notes" yaml:"notes"` + + // Changes are the changes made as part of the review. + Changes ReviewChanges `json:"changes" yaml:"changes"` +} + +// ReviewChanges are the changes that were made as part of the review. +type ReviewChanges struct { + // FrequencyChanged is populated if the audit frequency was changed. + FrequencyChanged time.Duration `json:"frequency_changed" yaml:"frequency_changed"` + + // MembershipRequirementsChanged is populated if the requirements were changed as part of this review. + MembershipRequirementsChanged *Requires `json:"membership_requirements_changed" yaml:"membership_requirements_changed"` + + // RemovedMembers contains the members that were removed as part of this review. + RemovedMembers []string `json:"removed_members" yaml:"removed_members"` +} + +// NewReview will create a new access list review. +func NewReview(metadata header.Metadata, spec ReviewSpec) (*Review, error) { + member := &Review{ + ResourceHeader: header.ResourceHeaderFromMetadata(metadata), + Spec: spec, + } + + if err := member.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return member, nil +} + +// CheckAndSetDefaults validates fields and populates empty fields with default values. +func (r *Review) CheckAndSetDefaults() error { + r.SetKind(types.KindAccessListReview) + r.SetVersion(types.V1) + + if err := r.ResourceHeader.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } + + if r.Spec.AccessList == "" { + return trace.BadParameter("access list is missing") + } + + if len(r.Spec.Reviewers) == 0 { + return trace.BadParameter("reviewers are missing") + } + + if r.Spec.ReviewDate.IsZero() { + return trace.BadParameter("review date is missing") + } + + return nil +} + +// GetMetadata returns metadata. This is specifically for conforming to the Resource interface, +// and should be removed when possible. +func (r *Review) GetMetadata() types.Metadata { + return legacy.FromHeaderMetadata(r.Metadata) +} + +func (r *ReviewSpec) UnmarshalJSON(data []byte) error { + type Alias ReviewSpec + review := struct { + ReviewDate string `json:"review_date"` + *Alias + }{ + Alias: (*Alias)(r), + } + if err := json.Unmarshal(data, &review); err != nil { + return trace.Wrap(err) + } + + var err error + r.ReviewDate, err = time.Parse(time.RFC3339Nano, review.ReviewDate) + if err != nil { + return trace.Wrap(err) + } + return nil +} + +func (r ReviewSpec) MarshalJSON() ([]byte, error) { + type Alias ReviewSpec + return json.Marshal(&struct { + ReviewDate string `json:"review_date"` + Alias + }{ + Alias: (Alias)(r), + ReviewDate: r.ReviewDate.Format(time.RFC3339Nano), + }) +} + +func (r *ReviewChanges) UnmarshalJSON(data []byte) error { + type Alias ReviewChanges + review := struct { + FrequencyChanged string `json:"frequency_changed"` + *Alias + }{ + Alias: (*Alias)(r), + } + if err := json.Unmarshal(data, &review); err != nil { + return trace.Wrap(err) + } + + var err error + r.FrequencyChanged, err = time.ParseDuration(review.FrequencyChanged) + if err != nil { + return trace.Wrap(err) + } + return nil +} + +func (r ReviewChanges) MarshalJSON() ([]byte, error) { + type Alias ReviewChanges + return json.Marshal(&struct { + FrequencyChanged string `json:"frequency_changed"` + Alias + }{ + Alias: (Alias)(r), + FrequencyChanged: r.FrequencyChanged.String(), + }) +} diff --git a/api/types/accesslist/review_test.go b/api/types/accesslist/review_test.go new file mode 100644 index 0000000000000..a1d29ac544526 --- /dev/null +++ b/api/types/accesslist/review_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2023 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 accesslist + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types/trait" +) + +func TestReviewSpecMarshaling(t *testing.T) { + reviewSpec := ReviewSpec{ + AccessList: "access-list", + Reviewers: []string{ + "user1", + "user2", + }, + ReviewDate: time.Date(2023, 01, 01, 0, 0, 0, 0, time.UTC), + Notes: "Some notes", + Changes: ReviewChanges{ + FrequencyChanged: 300 * time.Second, + MembershipRequirementsChanged: &Requires{ + Roles: []string{ + "member1", + "member2", + }, + Traits: trait.Traits{ + "trait1": []string{ + "value1", + "value2", + }, + "trait2": []string{ + "value1", + "value2", + }, + }, + }, + RemovedMembers: []string{ + "member1", + "member2", + }, + }, + } + + data, err := json.Marshal(&reviewSpec) + require.NoError(t, err) + + require.Equal(t, `{"review_date":"2023-01-01T00:00:00Z","access_list":"access-list","reviewers":["user1","user2"],`+ + `"notes":"Some notes","changes":{"frequency_changed":"5m0s","membership_requirements_changed":`+ + `{"roles":["member1","member2"],"traits":{"trait1":["value1","value2"],"trait2":["value1","value2"]}},`+ + `"removed_members":["member1","member2"]}}`, string(data)) + + raw := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &raw)) + + require.Equal(t, "2023-01-01T00:00:00Z", raw["review_date"]) + require.Equal(t, "5m0s", raw["changes"].(map[string]interface{})["frequency_changed"]) +} + +func TestReviewSpecUnmarshaling(t *testing.T) { + raw := map[string]interface{}{ + "access_list": "access-list", + "reviewers": []string{ + "user1", + "user2", + }, + "review_date": "2023-01-01T00:00:00Z", + "notes": "Some notes", + "changes": map[string]interface{}{ + "frequency_changed": "5m0s", + "membership_requirements_changed": map[string]interface{}{ + "roles": []string{ + "member1", + "member2", + }, + "traits": map[string]interface{}{ + "trait1": []string{ + "value1", + "value2", + }, + "trait2": []string{ + "value1", + "value2", + }, + }, + }, + "removed_members": []string{ + "member1", + "member2", + }, + }, + } + + data, err := json.Marshal(&raw) + require.NoError(t, err) + + var reviewSpec ReviewSpec + require.NoError(t, json.Unmarshal(data, &reviewSpec)) + + require.Equal(t, time.Date(2023, 01, 01, 0, 0, 0, 0, time.UTC), reviewSpec.ReviewDate) + require.Equal(t, 300*time.Second, reviewSpec.Changes.FrequencyChanged) +} diff --git a/api/types/constants.go b/api/types/constants.go index 1847c86a8383e..f99240598a0ac 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -460,6 +460,9 @@ const ( // KindAccessListMember is an AccessListMember resource KindAccessListMember = "access_list_member" + // KindAccessListReview is an AccessListReview resource + KindAccessListReview = "access_list_review" + // KindDiscoveryConfig is a DiscoveryConfig resource. // Used for adding additional matchers in Discovery Service. KindDiscoveryConfig = "discovery_config"