diff --git a/charts/lfx-v2-meeting-service/templates/ruleset.yaml b/charts/lfx-v2-meeting-service/templates/ruleset.yaml index 3b1cae4..aaf4467 100644 --- a/charts/lfx-v2-meeting-service/templates/ruleset.yaml +++ b/charts/lfx-v2-meeting-service/templates/ruleset.yaml @@ -698,8 +698,8 @@ spec: - authorizer: openfga_check config: values: - relation: viewer - object: "v1_past_meeting_summary:{{ "{{- .Request.URL.Captures.summary_uid -}}" }}" + relation: ai_summary_viewer + object: "v1_past_meeting:{{ "{{- .Request.URL.Captures.past_meeting_id -}}" }}" {{- else }} {{/* When OpenFGA is disabled, allow all requests @@ -730,7 +730,7 @@ spec: config: values: relation: organizer - object: "v1_past_meeting_summary:{{ "{{- .Request.URL.Captures.summary_uid -}}" }}" + object: "v1_past_meeting:{{ "{{- .Request.URL.Captures.past_meeting_id -}}" }}" {{- else }} {{/* When OpenFGA is disabled, allow all requests diff --git a/docs/indexer-contract.md b/docs/indexer-contract.md index d536102..62bcbb3 100644 --- a/docs/indexer-contract.md +++ b/docs/indexer-contract.md @@ -612,8 +612,8 @@ Used by `created_by`, `updated_by`, and entries in `updated_by_list`: | Field | Value | |---|---| -| `access_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` (access checked on the parent past meeting) | -| `access_check_relation` | `viewer` | +| `access_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` | +| `access_check_relation` | `recording_viewer` | | `history_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` | | `history_check_relation` | `auditor` | | `public` | `true` when `recording_access == "public"`, `false` otherwise | @@ -687,8 +687,8 @@ Used by `created_by`, `updated_by`, and entries in `updated_by_list`: | Field | Value | |---|---| -| `access_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` (access checked on the parent past meeting) | -| `access_check_relation` | `viewer` | +| `access_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` | +| `access_check_relation` | `transcript_viewer` | | `history_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` | | `history_check_relation` | `auditor` | | `public` | `true` when `transcript_access == "public"`, `false` otherwise | @@ -769,11 +769,11 @@ Used by `created_by`, `updated_by`, and entries in `updated_by_list`: | Field | Value | |---|---| -| `access_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` (access checked on the parent past meeting) | -| `access_check_relation` | `viewer` | +| `access_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` | +| `access_check_relation` | `ai_summary_viewer` | | `history_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` | | `history_check_relation` | `auditor` | -| `public` | `true` when the parent past meeting's `ai_summary_access == "public"`, `false` otherwise | +| `public` | `true` when `ai_summary_access == "public"`, `false` otherwise | ### Search Behavior diff --git a/internal/infrastructure/eventing/nats_publisher.go b/internal/infrastructure/eventing/nats_publisher.go index 87e87d5..c467440 100644 --- a/internal/infrastructure/eventing/nats_publisher.go +++ b/internal/infrastructure/eventing/nats_publisher.go @@ -236,7 +236,11 @@ func (p *NATSPublisher) PublishInviteResponseEvent(ctx context.Context, action s // PublishPastMeetingEvent publishes a past meeting event to indexer and FGA-sync services func (p *NATSPublisher) PublishPastMeetingEvent(ctx context.Context, action string, meeting *models.PastMeetingEventData) error { - p.logger.InfoContext(ctx, "publishing past meeting event", "action", action, "past_meeting_id", meeting.ID) + if meeting.MeetingAndOccurrenceID == "" { + return domain.NewValidationError("meeting_and_occurrence_id is required for publishing messages about the past meeting") + } + + p.logger.InfoContext(ctx, "publishing past meeting event", "action", action, "past_meeting_id", meeting.MeetingAndOccurrenceID) tags := meeting.Tags() publicFalse := false @@ -246,11 +250,11 @@ func (p *NATSPublisher) PublishPastMeetingEvent(ctx context.Context, action stri Data: meeting, Tags: tags, IndexingConfig: &indexerTypes.IndexingConfig{ - ObjectID: meeting.ID, + ObjectID: meeting.MeetingAndOccurrenceID, Public: &publicFalse, - AccessCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + meeting.ID, + AccessCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + meeting.MeetingAndOccurrenceID, AccessCheckRelation: "viewer", - HistoryCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + meeting.ID, + HistoryCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + meeting.MeetingAndOccurrenceID, HistoryCheckRelation: "auditor", ParentRefs: meeting.ParentRefs(), Tags: tags, @@ -265,7 +269,11 @@ func (p *NATSPublisher) PublishPastMeetingEvent(ctx context.Context, action stri } // Publish past meeting access control via generic FGA handler. + // Per-artifact conditional relations (recording_viewer, transcript_viewer, ai_summary_viewer) + // are written here — not in the artifact publishers — so FGA is updated whenever the past + // meeting record changes, not only when an artifact is re-published. pastMeetingRefs := map[string][]string{} + pastMeetingRelations := map[string][]string{} if meeting.MeetingID != "" { pastMeetingRefs["meeting"] = []string{"v1_meeting:" + meeting.MeetingID} } @@ -282,14 +290,54 @@ func (p *NATSPublisher) PublishPastMeetingEvent(ctx context.Context, action stri pastMeetingRefs["committee"] = committeeUIDs } + // Per-artifact access: self-referential references enable role-based access + // via the existing host/attendee/invitee tuples on the same v1_past_meeting object. + selfRef := "v1_past_meeting:" + meeting.MeetingAndOccurrenceID + + switch meeting.RecordingAccess { + case "public": + pastMeetingRelations["recording_viewer"] = []string{"*"} + case "meeting_participants": + pastMeetingRefs["past_meeting_for_host_recording_view"] = []string{selfRef} + pastMeetingRefs["past_meeting_for_attendee_recording_view"] = []string{selfRef} + pastMeetingRefs["past_meeting_for_participant_recording_view"] = []string{selfRef} + default: // meeting_hosts or unset + pastMeetingRefs["past_meeting_for_host_recording_view"] = []string{selfRef} + } + + switch meeting.TranscriptAccess { + case "public": + pastMeetingRelations["transcript_viewer"] = []string{"*"} + case "meeting_participants": + pastMeetingRefs["past_meeting_for_host_transcript_view"] = []string{selfRef} + pastMeetingRefs["past_meeting_for_attendee_transcript_view"] = []string{selfRef} + pastMeetingRefs["past_meeting_for_participant_transcript_view"] = []string{selfRef} + default: // meeting_hosts or unset + pastMeetingRefs["past_meeting_for_host_transcript_view"] = []string{selfRef} + } + + switch meeting.AISummaryAccess { + case "public": + pastMeetingRelations["ai_summary_viewer"] = []string{"*"} + case "meeting_participants": + pastMeetingRefs["past_meeting_for_host_summary_view"] = []string{selfRef} + pastMeetingRefs["past_meeting_for_attendee_summary_view"] = []string{selfRef} + pastMeetingRefs["past_meeting_for_participant_summary_view"] = []string{selfRef} + default: // meeting_hosts or unset + pastMeetingRefs["past_meeting_for_host_summary_view"] = []string{selfRef} + } + pastMeetingAccessMsg := fgatypes.GenericFGAMessage{ ObjectType: "v1_past_meeting", Operation: "update_access", Data: fgatypes.GenericAccessData{ - UID: meeting.ID, + UID: meeting.MeetingAndOccurrenceID, Public: false, - Relations: map[string][]string{}, + Relations: pastMeetingRelations, References: pastMeetingRefs, + // host/invitee/attendee are managed by PublishPastMeetingParticipantEvent + // and must not be overwritten here. + ExcludeRelations: []string{"host", "invitee", "attendee"}, }, } @@ -388,7 +436,7 @@ func (p *NATSPublisher) PublishPastMeetingRecordingEvent(ctx context.Context, ac ObjectID: recording.ID, Public: &isPublic, AccessCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + recording.MeetingAndOccurrenceID, - AccessCheckRelation: "viewer", + AccessCheckRelation: "recording_viewer", HistoryCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + recording.MeetingAndOccurrenceID, HistoryCheckRelation: "auditor", ParentRefs: recording.ParentRefs(), @@ -403,39 +451,9 @@ func (p *NATSPublisher) PublishPastMeetingRecordingEvent(ctx context.Context, ac return fmt.Errorf("failed to publish recording to indexer: %w", err) } - // Publish recording access control via generic FGA handler. - // references builds object-to-object tuples; values use "v1_past_meeting:" so fga-sync - // writes the correct type (define past_meeting: [v1_past_meeting]). - pastMeetingRef := "v1_past_meeting:" + recording.MeetingAndOccurrenceID - recordingRefs := map[string][]string{ - "past_meeting": {pastMeetingRef}, - } - switch recording.RecordingAccess { - case "public": - // isPublic=true handles viewer access via user:* - case "meeting_participants": - recordingRefs["past_meeting_for_host_view"] = []string{pastMeetingRef} - recordingRefs["past_meeting_for_attendee_view"] = []string{pastMeetingRef} - recordingRefs["past_meeting_for_participant_view"] = []string{pastMeetingRef} - default: // meeting_hosts or unset - recordingRefs["past_meeting_for_host_view"] = []string{pastMeetingRef} - } - - recordingAccessMsg := fgatypes.GenericFGAMessage{ - ObjectType: "v1_past_meeting_recording", - Operation: "update_access", - Data: fgatypes.GenericAccessData{ - UID: recording.ID, - Public: isPublic, - Relations: map[string][]string{}, - References: recordingRefs, - }, - } - - if err := p.publish(ctx, fgaconstants.GenericUpdateAccessSubject, recordingAccessMsg); err != nil { - return fmt.Errorf("failed to publish recording access control: %w", err) - } - + // FGA access for recordings is managed in PublishPastMeetingEvent, not here, + // because recording_access lives on the past meeting record. This ensures FGA + // stays in sync when the access setting changes without a new recording event. return nil } @@ -454,7 +472,7 @@ func (p *NATSPublisher) PublishPastMeetingTranscriptEvent(ctx context.Context, a ObjectID: transcript.ID, Public: &isPublic, AccessCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + transcript.MeetingAndOccurrenceID, - AccessCheckRelation: "viewer", + AccessCheckRelation: "transcript_viewer", HistoryCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + transcript.MeetingAndOccurrenceID, HistoryCheckRelation: "auditor", ParentRefs: transcript.ParentRefs(), @@ -469,37 +487,8 @@ func (p *NATSPublisher) PublishPastMeetingTranscriptEvent(ctx context.Context, a return fmt.Errorf("failed to publish transcript to indexer: %w", err) } - // Publish transcript access control via generic FGA handler. - pastMeetingRef := "v1_past_meeting:" + transcript.MeetingAndOccurrenceID - transcriptRefs := map[string][]string{ - "past_meeting": {pastMeetingRef}, - } - switch transcript.TranscriptAccess { - case "public": - // isPublic=true handles viewer access via user:* - case "meeting_participants": - transcriptRefs["past_meeting_for_host_view"] = []string{pastMeetingRef} - transcriptRefs["past_meeting_for_attendee_view"] = []string{pastMeetingRef} - transcriptRefs["past_meeting_for_participant_view"] = []string{pastMeetingRef} - default: // meeting_hosts or unset - transcriptRefs["past_meeting_for_host_view"] = []string{pastMeetingRef} - } - - transcriptAccessMsg := fgatypes.GenericFGAMessage{ - ObjectType: "v1_past_meeting_transcript", - Operation: "update_access", - Data: fgatypes.GenericAccessData{ - UID: transcript.ID, - Public: isPublic, - Relations: map[string][]string{}, - References: transcriptRefs, - }, - } - - if err := p.publish(ctx, fgaconstants.GenericUpdateAccessSubject, transcriptAccessMsg); err != nil { - return fmt.Errorf("failed to publish transcript access control: %w", err) - } - + // FGA access for transcripts is managed in PublishPastMeetingEvent, not here, + // because transcript_access lives on the past meeting record. return nil } @@ -519,7 +508,7 @@ func (p *NATSPublisher) PublishPastMeetingSummaryEvent(ctx context.Context, acti ObjectID: summary.ID, Public: &isPublic, AccessCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + summary.MeetingAndOccurrenceID, - AccessCheckRelation: "viewer", + AccessCheckRelation: "ai_summary_viewer", HistoryCheckObject: indexerConstants.ObjectTypeV1PastMeeting + ":" + summary.MeetingAndOccurrenceID, HistoryCheckRelation: "auditor", ParentRefs: summary.ParentRefs(), @@ -534,37 +523,8 @@ func (p *NATSPublisher) PublishPastMeetingSummaryEvent(ctx context.Context, acti return fmt.Errorf("failed to publish summary to indexer: %w", err) } - // Publish summary access control via generic FGA handler. - pastMeetingRef := "v1_past_meeting:" + summary.MeetingAndOccurrenceID - summaryRefs := map[string][]string{ - "past_meeting": {pastMeetingRef}, - } - switch summaryAccess { - case "public": - // isPublic=true handles viewer access via user:* - case "meeting_participants": - summaryRefs["past_meeting_for_host_view"] = []string{pastMeetingRef} - summaryRefs["past_meeting_for_attendee_view"] = []string{pastMeetingRef} - summaryRefs["past_meeting_for_participant_view"] = []string{pastMeetingRef} - default: // meeting_hosts or unset - summaryRefs["past_meeting_for_host_view"] = []string{pastMeetingRef} - } - - summaryAccessMsg := fgatypes.GenericFGAMessage{ - ObjectType: "v1_past_meeting_summary", - Operation: "update_access", - Data: fgatypes.GenericAccessData{ - UID: summary.ID, - Public: isPublic, - Relations: map[string][]string{}, - References: summaryRefs, - }, - } - - if err := p.publish(ctx, fgaconstants.GenericUpdateAccessSubject, summaryAccessMsg); err != nil { - return fmt.Errorf("failed to publish summary access control: %w", err) - } - + // FGA access for summaries is managed in PublishPastMeetingEvent, not here, + // because ai_summary_access lives on the past meeting record. return nil }