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
6 changes: 3 additions & 3 deletions charts/lfx-v2-meeting-service/templates/ruleset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/indexer-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Comment thread
andrest50 marked this conversation as resolved.
| `history_check_object` | `v1_past_meeting:{meeting_and_occurrence_id}` |
| `history_check_relation` | `auditor` |
| `public` | `true` when `recording_access == "public"`, `false` otherwise |
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Comment thread
andrest50 marked this conversation as resolved.

### Search Behavior

Expand Down
168 changes: 64 additions & 104 deletions internal/infrastructure/eventing/nats_publisher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
andrest50 marked this conversation as resolved.

tags := meeting.Tags()
publicFalse := false
Expand All @@ -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",
Comment thread
andrest50 marked this conversation as resolved.
ParentRefs: meeting.ParentRefs(),
Tags: tags,
Expand All @@ -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.
Comment thread
andrest50 marked this conversation as resolved.
pastMeetingRefs := map[string][]string{}
pastMeetingRelations := map[string][]string{}
if meeting.MeetingID != "" {
pastMeetingRefs["meeting"] = []string{"v1_meeting:" + meeting.MeetingID}
}
Expand All @@ -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.
Comment thread
andrest50 marked this conversation as resolved.
selfRef := "v1_past_meeting:" + meeting.MeetingAndOccurrenceID
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I see meeting.MeetingAndOccurrenceID is used multiple times, also for the access. Can we add a validation

if meeting.MeetingAndOccurrenceID == "" {
    return domain.NewValidationError("meeting_and_occurrence_id is required for past meeting FGA access update")
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a good point. And actually when we send the indexer message using meeting.ID, that is the same value as the meeting.MeetingAndOccurrenceID, so I switched to using meeting.MeetingAndOccurrenceID in all places in the code and moved the validation to the top of the function. There aren't any cases where this validation has failed currently since all meetings have an ID, but it is still a good safety check.


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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// host/invitee/attendee are managed by PublishPastMeetingParticipantEvent
// and must not be overwritten here.
ExcludeRelations: []string{"host", "invitee", "attendee"},
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
andrest50 marked this conversation as resolved.
}

Expand Down Expand Up @@ -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(),
Expand All @@ -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:<id>" 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
}

Expand All @@ -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(),
Expand All @@ -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
}

Expand All @@ -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(),
Expand All @@ -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
}

Expand Down
Loading