diff --git a/charts/lfx-v2-committee-service/Chart.yaml b/charts/lfx-v2-committee-service/Chart.yaml index 7245904..9ee6c31 100644 --- a/charts/lfx-v2-committee-service/Chart.yaml +++ b/charts/lfx-v2-committee-service/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: lfx-v2-committee-service description: LFX Platform V2 Committee Service chart type: application -version: 0.2.19 +version: 0.2.20 appVersion: "latest" diff --git a/charts/lfx-v2-committee-service/templates/ruleset.yaml b/charts/lfx-v2-committee-service/templates/ruleset.yaml index 8a8a9e4..9f9d6fd 100644 --- a/charts/lfx-v2-committee-service/templates/ruleset.yaml +++ b/charts/lfx-v2-committee-service/templates/ruleset.yaml @@ -245,7 +245,7 @@ spec: - authorizer: openfga_check config: values: - relation: viewer + relation: basic_profile_viewer object: "committee:{{ "{{- .Request.URL.Captures.uid -}}" }}" {{- else }} - authorizer: allow_all diff --git a/internal/domain/model/committee_base.go b/internal/domain/model/committee_base.go index de5e504..3e64244 100644 --- a/internal/domain/model/committee_base.go +++ b/internal/domain/model/committee_base.go @@ -18,6 +18,8 @@ import ( const ( categoryGovernmentAdvisoryCouncil = "Government Advisory Council" + + memberVisibilityBasicProfileSetting = "basic_profile" ) // Committee represents the core committee business entity @@ -155,3 +157,12 @@ func (c *Committee) Tags() []string { func (c *Committee) IsGovernmentAdvisoryCouncil() bool { return c.Category == categoryGovernmentAdvisoryCouncil } + +// IsMemberVisibilityBasicProfile returns true if the committee's member visibility setting is "basic_profile" +func (c *Committee) IsMemberVisibilityBasicProfile() bool { + if c.CommitteeSettings == nil { + return false + } + + return c.MemberVisibility == memberVisibilityBasicProfileSetting +} diff --git a/internal/domain/model/committee_base_test.go b/internal/domain/model/committee_base_test.go index 43732db..20afd09 100644 --- a/internal/domain/model/committee_base_test.go +++ b/internal/domain/model/committee_base_test.go @@ -590,3 +590,66 @@ func BenchmarkCommitteeTags_Parallel(b *testing.B) { } }) } + +func TestCommitteeIsMemberVisibilityBasicProfile(t *testing.T) { + tests := []struct { + name string + committee Committee + expected bool + }{ + { + name: "returns true when member visibility is basic_profile", + committee: Committee{ + CommitteeBase: CommitteeBase{}, + CommitteeSettings: &CommitteeSettings{ + MemberVisibility: "basic_profile", + }, + }, + expected: true, + }, + { + name: "returns false when member visibility is full_profile", + committee: Committee{ + CommitteeBase: CommitteeBase{}, + CommitteeSettings: &CommitteeSettings{ + MemberVisibility: "full_profile", + }, + }, + expected: false, + }, + { + name: "returns false when member visibility is empty string", + committee: Committee{ + CommitteeBase: CommitteeBase{}, + CommitteeSettings: &CommitteeSettings{ + MemberVisibility: "", + }, + }, + expected: false, + }, + { + name: "returns false when member visibility is other value", + committee: Committee{ + CommitteeBase: CommitteeBase{}, + CommitteeSettings: &CommitteeSettings{ + MemberVisibility: "private", + }, + }, + expected: false, + }, + { + name: "returns false when CommitteeSettings is nil", + committee: Committee{ + CommitteeBase: CommitteeBase{}, + CommitteeSettings: nil, + }, + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, tc.committee.IsMemberVisibilityBasicProfile()) + }) + } +} diff --git a/internal/domain/model/committee_message.go b/internal/domain/model/committee_message.go index 328116e..6282085 100644 --- a/internal/domain/model/committee_message.go +++ b/internal/domain/model/committee_message.go @@ -109,6 +109,9 @@ type CommitteeAccessMessage struct { // e.g. "project" and it's value is the project UID. // e.g. "parent" and it's value is the parent UID. References map[string]string `json:"references"` + // Self stores OpenFGA self-relation tuples that enable conditional member-to-member visibility. + // When populated, members can view other members' basic profiles based on committee visibility settings. + Self []string `json:"self"` } // CommitteeMemberUpdateEventData represents the data structure for committee member update events diff --git a/internal/service/committee_writer.go b/internal/service/committee_writer.go index 78b0001..724a5d6 100644 --- a/internal/service/committee_writer.go +++ b/internal/service/committee_writer.go @@ -228,6 +228,7 @@ func (uc *committeeWriterOrchestrator) buildAccessControlMessage(ctx context.Con // project is required in the flow constants.RelationProject: committee.ProjectUID, }, + Self: []string{}, } if committee.CommitteeSettings != nil && len(committee.Writers) > 0 { @@ -238,6 +239,10 @@ func (uc *committeeWriterOrchestrator) buildAccessControlMessage(ctx context.Con message.Relations[constants.RelationAuditor] = committee.Auditors } + if committee.CommitteeSettings != nil && committee.IsMemberVisibilityBasicProfile() { + message.Self = append(message.Self, constants.RelationSelfForMemberBasicProfileAccess) + } + slog.DebugContext(ctx, "building access control message", "message", message, ) diff --git a/internal/service/committee_writer_test.go b/internal/service/committee_writer_test.go index dd4f70f..d3068a7 100644 --- a/internal/service/committee_writer_test.go +++ b/internal/service/committee_writer_test.go @@ -516,6 +516,7 @@ func TestCommitteeWriterOrchestrator_buildAccessControlMessage(t *testing.T) { References: map[string]string{ "project": "project-1", }, + Self: []string{}, }, }, { @@ -542,6 +543,7 @@ func TestCommitteeWriterOrchestrator_buildAccessControlMessage(t *testing.T) { References: map[string]string{ "project": "project-2", }, + Self: []string{}, }, }, { @@ -563,6 +565,63 @@ func TestCommitteeWriterOrchestrator_buildAccessControlMessage(t *testing.T) { References: map[string]string{ "project": "project-3", }, + Self: []string{}, + }, + }, + { + name: "committee with basic_profile member visibility", + committee: &model.Committee{ + CommitteeBase: model.CommitteeBase{ + UID: "committee-4", + ProjectUID: "project-4", + Public: false, + ParentUID: nil, + }, + CommitteeSettings: &model.CommitteeSettings{ + MemberVisibility: "basic_profile", + Writers: []string{"writer@example.com"}, + Auditors: []string{"auditor@example.com"}, + }, + }, + expected: &model.CommitteeAccessMessage{ + UID: "committee-4", + ObjectType: "committee", + Public: false, + Relations: map[string][]string{ + "writer": {"writer@example.com"}, + "auditor": {"auditor@example.com"}, + }, + References: map[string]string{ + "project": "project-4", + }, + Self: []string{"self_for_member_basic_profile_access"}, + }, + }, + { + name: "committee with non-basic_profile member visibility", + committee: &model.Committee{ + CommitteeBase: model.CommitteeBase{ + UID: "committee-5", + ProjectUID: "project-5", + Public: true, + ParentUID: nil, + }, + CommitteeSettings: &model.CommitteeSettings{ + MemberVisibility: "full_profile", + Writers: []string{"writer@example.com"}, + }, + }, + expected: &model.CommitteeAccessMessage{ + UID: "committee-5", + ObjectType: "committee", + Public: true, + Relations: map[string][]string{ + "writer": {"writer@example.com"}, + }, + References: map[string]string{ + "project": "project-5", + }, + Self: []string{}, }, }, } diff --git a/pkg/constants/access_control.go b/pkg/constants/access_control.go index 141172a..582c7bc 100644 --- a/pkg/constants/access_control.go +++ b/pkg/constants/access_control.go @@ -12,4 +12,6 @@ const ( RelationWriter = "writer" // RelationAuditor is the relation name for the auditor of an object. RelationAuditor = "auditor" + // RelationSelfForMemberBasicProfileAccess is the relation name for committee members to access basic profile info. + RelationSelfForMemberBasicProfileAccess = "self_for_member_basic_profile_access" )