Skip to content

Commit 80ae669

Browse files
authored
Merge pull request #76 from linuxfoundation/andrest50/LFXV2-1442-project-uid-on-committee-member
[LFXV2-1442] feat: add project_uid and project_slug to committee member indexed records
2 parents 9bac414 + 806f902 commit 80ae669

11 files changed

Lines changed: 453 additions & 99 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ The LFX v2 Committee Service is a RESTful API service that manages committees an
5252
## Documentation
5353

5454
- [Invite & Application Flows](docs/invite-application-flows.md) — membership modes, invite/application lifecycle, state transitions, and edge cases
55+
- [Indexer Contract](docs/indexer-contract.md) — authoritative reference for all messages sent to the indexer service
56+
- [FGA Contract](docs/fga-contract.md) — authoritative reference for all messages sent to the fga-sync service
5557

5658
## Releases
5759

docs/fga-contract.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# FGA Contract — Committee Service
2+
3+
This document is the authoritative reference for all messages the committee service sends to the fga-sync service, which writes and deletes [OpenFGA](https://openfga.dev/) relationship tuples to enforce access control.
4+
5+
The full OpenFGA type definitions (relations, schema) for all object types are defined in the [platform model](https://github.com/linuxfoundation/lfx-v2-helm/blob/main/charts/lfx-platform/templates/openfga/model.yaml).
6+
7+
**Update this document in the same PR as any change to FGA message construction.**
8+
9+
---
10+
11+
## Object Types
12+
13+
- [Committee](#committee)
14+
15+
---
16+
17+
## Message Format
18+
19+
All messages use the generic FGA message format on the following NATS subjects:
20+
21+
| Subject | Used for |
22+
|---|---|
23+
| `lfx.fga-sync.update_access` | Create and update operations |
24+
| `lfx.fga-sync.delete_access` | Delete operations |
25+
| `lfx.fga-sync.member_put` | Add or update individual committee members |
26+
| `lfx.fga-sync.member_remove` | Remove individual committee members |
27+
28+
Each message carries `object_type`, `operation`, and a `data` map. The sections below describe the `data` contents for each operation.
29+
30+
---
31+
32+
## Committee
33+
34+
**Source struct:** `internal/domain/model/``Committee` (base + settings)
35+
36+
**Synced on:** create, update of committee base, update of committee settings, delete of a committee. Committee member changes are synced separately via `member_put`.
37+
38+
### update_access
39+
40+
Published to `lfx.fga-sync.update_access` on committee create or update (base or settings).
41+
42+
#### Message Envelope
43+
44+
| Field | Value |
45+
|---|---|
46+
| `object_type` | `committee` |
47+
| `operation` | `update_access` |
48+
49+
#### Data Fields
50+
51+
These fields are carried inside the message `data` object.
52+
53+
| Field | Value |
54+
|---|---|
55+
| `uid` | `CommitteeBase.UID` |
56+
| `public` | `CommitteeBase.Public` (passed through directly) |
57+
58+
#### Relations
59+
60+
| Relation | Value | Condition |
61+
|---|---|---|
62+
| `writer` | Usernames from `CommitteeSettings.Writers` | Only when `Writers` is non-empty |
63+
| `auditor` | Usernames from `CommitteeSettings.Auditors` | Only when `Auditors` is non-empty |
64+
65+
> Usernames are the `Username` field of each `CommitteeUser` entry (Auth0 `sub` values). Users with an empty `Username` are skipped.
66+
67+
#### References
68+
69+
| Reference | Value | Condition |
70+
|---|---|---|
71+
| `project` | `CommitteeBase.ProjectUID` | Always |
72+
73+
#### Exclude Relations
74+
75+
`exclude_relations: ["member"]` — always set. Individual committee members are managed via `member_put` and must not be overwritten by the `update_access` handler.
76+
77+
### member_put (Committee Member Create/Update)
78+
79+
Published to `lfx.fga-sync.member_put` when a committee member is created or updated and the member has a non-empty `Username`.
80+
81+
The object UID is the **committee UID** (`CommitteeBase.UID`), not the member UID.
82+
83+
#### Message Envelope
84+
85+
| Field | Value |
86+
|---|---|
87+
| `object_type` | `committee` |
88+
| `operation` | `member_put` |
89+
90+
#### Data (`FGAMemberData`)
91+
92+
| Field | Value | Condition |
93+
|---|---|---|
94+
| `uid` | `CommitteeMember.CommitteeUID` (parent committee) | Always |
95+
| `username` | `CommitteeMember.Username` (Auth0 `sub`) | Always (skipped if `Username` is empty) |
96+
| `relations` | `["member"]` | Always |
97+
98+
### member_remove (Committee Member Delete)
99+
100+
Published to `lfx.fga-sync.member_remove` when a committee member is deleted and the member has a non-empty `Username`. Sends an empty `relations` array, which instructs fga-sync to remove all tuples for that user on the committee object.
101+
102+
#### Message Envelope
103+
104+
| Field | Value |
105+
|---|---|
106+
| `object_type` | `committee` |
107+
| `operation` | `member_remove` |
108+
109+
#### Data (`FGAMemberData`)
110+
111+
| Field | Value | Condition |
112+
|---|---|---|
113+
| `uid` | `CommitteeMember.CommitteeUID` (parent committee) | Always |
114+
| `username` | `CommitteeMember.Username` (Auth0 `sub`) | Always (skipped if `Username` is empty) |
115+
| `relations` | `[]` (empty — remove all) | Always |
116+
117+
### Delete
118+
119+
On delete, a `delete_access` message is sent to `lfx.fga-sync.delete_access` with only the committee `uid` — all FGA tuples for `committee:{uid}` are removed by the fga-sync service.
120+
121+
---
122+
123+
## Triggers
124+
125+
| Operation | Object Type | Subject | Notes |
126+
|---|---|---|---|
127+
| Create committee | `committee` | `lfx.fga-sync.update_access` | Always sent |
128+
| Update committee base | `committee` | `lfx.fga-sync.update_access` | Always sent |
129+
| Update committee settings | `committee` | `lfx.fga-sync.update_access` | Always sent |
130+
| Delete committee | `committee` | `lfx.fga-sync.delete_access` | Always sent |
131+
| Create committee member (with username) | `committee` | `lfx.fga-sync.member_put` | Skipped if `Username` is empty |
132+
| Update committee member (with username) | `committee` | `lfx.fga-sync.member_put` | Skipped if `Username` is empty |
133+
| Delete committee member (with username) | `committee` | `lfx.fga-sync.member_remove` | Skipped if `Username` is empty; empty relations removes all tuples for the user |

docs/indexer-contract.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ This document is the authoritative reference for all data the committee service
2020

2121
## Committee
2222

23+
**Object type:** `committee`
24+
25+
**NATS subject:** `lfx.index.committee`
26+
2327
**Source struct:** `internal/domain/model/committee_base.go``CommitteeBase`
2428

2529
**Indexed on:** create, update, delete of a committee.
@@ -94,6 +98,10 @@ These fields are indexed and queryable via `filters` or `cel_filter` in the quer
9498

9599
## Committee Settings
96100

101+
**Object type:** `committee_settings`
102+
103+
**NATS subject:** `lfx.index.committee_settings`
104+
97105
**Source struct:** `internal/domain/model/committee_settings.go``CommitteeSettings`
98106

99107
**Indexed on:** create, update, delete of committee settings. Settings share the same UID as their parent committee.
@@ -143,6 +151,10 @@ _(none)_
143151

144152
## Committee Member
145153

154+
**Object type:** `committee_member`
155+
156+
**NATS subject:** `lfx.index.committee_member`
157+
146158
**Source struct:** `internal/domain/model/committee_member.go``CommitteeMember`
147159

148160
**Indexed on:** create, update, delete of a committee member.
@@ -155,6 +167,8 @@ _(none)_
155167
| `committee_uid` | string | UID of the committee this member belongs to |
156168
| `committee_name` | string | Name of the committee |
157169
| `committee_category` | string | Category of the committee |
170+
| `project_uid` | string (optional) | UID of the owning project |
171+
| `project_slug` | string (optional) | Slug of the owning project |
158172
| `username` | string | Member's username |
159173
| `email` | string | Member's email address |
160174
| `first_name` | string | Member's first name |
@@ -189,8 +203,10 @@ _(none)_
189203
| `organization_id:{value}` | `organization_id:org-789` | Find members by organization ID |
190204
| `organization_name:{value}` | `organization_name:The Linux Foundation` | Find members by organization name |
191205
| `organization_website:{value}` | `organization_website:linuxfoundation.org` | Find members by organization website |
206+
| `project_uid:{value}` | `project_uid:cbef1ed5-17dc-4a50-84e2-6cddd70f6878` | Find members by project UID |
207+
| `project_slug:{value}` | `project_slug:test-project` | Find members by project slug |
192208

193-
> Tags for `username`, `email`, `voting_status`, `organization_id`, `organization_name`, and `organization_website` are only emitted when the value is non-empty.
209+
> Tags for `username`, `email`, `voting_status`, `organization_id`, `organization_name`, `organization_website`, `project_uid`, and `project_slug` are only emitted when the value is non-empty.
194210
195211
### Access Control (IndexingConfig)
196212

@@ -220,6 +236,10 @@ _(none)_
220236

221237
## Committee Invite
222238

239+
**Object type:** `committee_invite`
240+
241+
**NATS subject:** `lfx.index.committee_invite`
242+
223243
**Source struct:** `internal/domain/model/committee_invite.go``CommitteeInvite`
224244

225245
**Indexed on:** create, update, delete of a committee invite.
@@ -275,6 +295,10 @@ _(none)_
275295

276296
## Committee Application
277297

298+
**Object type:** `committee_application`
299+
300+
**NATS subject:** `lfx.index.committee_application`
301+
278302
**Source struct:** `internal/domain/model/committee_application.go``CommitteeApplication`
279303

280304
**Indexed on:** create, update, delete of a committee application.
@@ -331,6 +355,10 @@ _(none)_
331355

332356
## Committee Link
333357

358+
**Object type:** `committee_link`
359+
360+
**NATS subject:** `lfx.index.committee_link`
361+
334362
**Source struct:** `internal/domain/model/committee_link.go``CommitteeLink`
335363

336364
**Indexed on:** create, update, delete of a committee link.
@@ -390,6 +418,10 @@ _(none)_
390418

391419
## Committee Link Folder
392420

421+
**Object type:** `committee_link_folder`
422+
423+
**NATS subject:** `lfx.index.committee_link_folder`
424+
393425
**Source struct:** `internal/domain/model/committee_link.go``CommitteeLinkFolder`
394426

395427
**Indexed on:** create, update, delete of a committee link folder.

internal/domain/model/committee_member.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ type CommitteeMemberBase struct {
3838
CommitteeUID string `json:"committee_uid"`
3939
CommitteeName string `json:"committee_name"`
4040
CommitteeCategory string `json:"committee_category"`
41+
ProjectUID string `json:"project_uid,omitempty"`
42+
ProjectSlug string `json:"project_slug,omitempty"`
4143
CreatedAt time.Time `json:"created_at"`
4244
UpdatedAt time.Time `json:"updated_at"`
4345
}
@@ -117,6 +119,16 @@ func (cm *CommitteeMember) Tags() []string {
117119
tags = append(tags, tag)
118120
}
119121

122+
if cm.ProjectUID != "" {
123+
tag := fmt.Sprintf("project_uid:%s", cm.ProjectUID)
124+
tags = append(tags, tag)
125+
}
126+
127+
if cm.ProjectSlug != "" {
128+
tag := fmt.Sprintf("project_slug:%s", cm.ProjectSlug)
129+
tags = append(tags, tag)
130+
}
131+
120132
if cm.Username != "" {
121133
tag := fmt.Sprintf("username:%s", cm.Username)
122134
tags = append(tags, tag)

internal/domain/model/committee_member_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,45 @@ func TestCommitteeMember_Tags(t *testing.T) {
363363
"organization_name:The Linux Foundation",
364364
},
365365
},
366+
{
367+
name: "member with project uid and slug",
368+
member: &CommitteeMember{
369+
CommitteeMemberBase: CommitteeMemberBase{
370+
UID: "member-123",
371+
CommitteeUID: "committee-456",
372+
373+
ProjectUID: "cbef1ed5-17dc-4a50-84e2-6cddd70f6878",
374+
ProjectSlug: "test-project",
375+
},
376+
},
377+
expected: []string{
378+
"member-123",
379+
"committee_member_uid:member-123",
380+
"committee_uid:committee-456",
381+
"project_uid:cbef1ed5-17dc-4a50-84e2-6cddd70f6878",
382+
"project_slug:test-project",
383+
384+
},
385+
},
386+
{
387+
name: "member with project uid only",
388+
member: &CommitteeMember{
389+
CommitteeMemberBase: CommitteeMemberBase{
390+
UID: "member-123",
391+
CommitteeUID: "committee-456",
392+
393+
ProjectUID: "cbef1ed5-17dc-4a50-84e2-6cddd70f6878",
394+
// Missing ProjectSlug
395+
},
396+
},
397+
expected: []string{
398+
"member-123",
399+
"committee_member_uid:member-123",
400+
"committee_uid:committee-456",
401+
"project_uid:cbef1ed5-17dc-4a50-84e2-6cddd70f6878",
402+
403+
},
404+
},
366405
}
367406

368407
for _, tt := range tests {

internal/domain/model/committee_message.go

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,23 +100,40 @@ func (c *CommitteeIndexerMessage) Build(ctx context.Context, input any) (*Commit
100100

101101
}
102102

103-
// CommitteeAccessMessage is the schema for the data in the message sent to the fga-sync service.
104-
// These are the fields that the fga-sync service needs in order to update the OpenFGA permissions.
105-
type CommitteeAccessMessage struct {
103+
// GenericFGAMessage is the envelope for all FGA sync operations.
104+
// It uses the generic, resource-agnostic FGA sync handlers.
105+
type GenericFGAMessage struct {
106+
ObjectType string `json:"object_type"` // Resource type, e.g. "committee"
107+
Operation string `json:"operation"` // Operation name, e.g. "update_access"
108+
Data any `json:"data"` // Operation-specific payload
109+
}
110+
111+
// FGAUpdateAccessData is the data payload for update_access operations.
112+
// This is a full sync — any relations not listed (and not excluded) will be removed.
113+
type FGAUpdateAccessData struct {
114+
UID string `json:"uid"`
115+
Public bool `json:"public"`
116+
Relations map[string][]string `json:"relations,omitempty"`
117+
References map[string][]string `json:"references,omitempty"`
118+
ExcludeRelations []string `json:"exclude_relations,omitempty"`
119+
}
120+
121+
// FGADeleteAccessData is the data payload for delete_access operations.
122+
type FGADeleteAccessData struct {
106123
UID string `json:"uid"`
107-
// object_type is the type of the object that the message is about, e.g. "committee" or "project".
108-
ObjectType string `json:"object_type"`
109-
// public is the public flag for the object.
110-
Public bool `json:"public"`
111-
// relations are used to store the relations of the object, e.g. "writer"
112-
// and it's value is a list of principals.
113-
Relations map[string][]string `json:"relations"`
114-
// references are used to store the references of the object,
115-
// e.g. "project" and it's value is the project UID.
116-
// e.g. "parent" and it's value is the parent UID.
117-
References map[string]string `json:"references"`
118124
}
119125

126+
// FGAMemberData is the data payload for member FGA operations (member_put and member_remove).
127+
type FGAMemberData struct {
128+
UID string `json:"uid"`
129+
Username string `json:"username"`
130+
Relations []string `json:"relations"`
131+
MutuallyExclusiveWith []string `json:"mutually_exclusive_with,omitempty"`
132+
}
133+
134+
// FGAMemberPutData is a backward-compatible alias for FGAMemberData.
135+
type FGAMemberPutData = FGAMemberData
136+
120137
// CommitteeMemberUpdateEventData represents the data structure for committee member update events
121138
type CommitteeMemberUpdateEventData struct {
122139
MemberUID string `json:"member_uid"`

0 commit comments

Comments
 (0)