Skip to content
Open
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
52 changes: 46 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,56 @@ Complete API endpoint documentation and NATS message handlers are now documented
"lfx.projects-api.get_slug" // Get project slug by UID
"lfx.projects-api.get_logo" // Get project logo URL by UID
"lfx.projects-api.slug_to_uid" // Convert slug to UID
"lfx.projects-api.get_parent_uid" // Get parent project UID

// Outbound events (published by this service)
"lfx.index.project" // Project created/updated for indexing
"lfx.index.project_settings" // Settings updated for indexing
"lfx.update_access.project" // Project access control updates
"lfx.update_access.project_settings" // Project settings access control updates
"lfx.delete_all_access.project" // Project access control deletion
"lfx.delete_all_access.project_settings" // Project settings access control deletion
"lfx.index.project" // Project created/updated/deleted for indexing
"lfx.index.project_settings" // Settings created/updated for indexing
"lfx.projects-api.project_settings.updated" // Settings changed (before/after)
"lfx.fga-sync.update_access" // Generic FGA access control updates
"lfx.fga-sync.delete_access" // Generic FGA access control deletion
```

### FGA Sync Message Format

The service uses the generic FGA sync handlers for access control. All messages use the `GenericFGAMessage` envelope:

```go
// Update access control (full sync)
GenericFGAMessage{
ObjectType: "project",
Operation: "update_access",
Data: UpdateAccessData{
UID: "project-uid",
Public: true,
Relations: map[string][]string{
"writer": []string{"username1", "username2"},
"auditor": []string{"username3"},
"meeting_coordinator": []string{"username4"},
},
References: map[string][]string{
"parent": []string{"project:parent-uid"},
},
},
}

// Delete all access control
GenericFGAMessage{
ObjectType: "project",
Operation: "delete_access",
Data: DeleteAccessData{
UID: "project-uid",
},
}
```

**Key Points:**

- Relations map user roles to usernames (e.g., `"writer": ["user1", "user2"]`)
- References map object relationships with formatted UIDs (e.g., `"parent": ["project:parent-uid"]`)
- Update operations are full sync - any relations not included will be removed
- Delete operations remove all access control tuples for the resource

## Testing Patterns

### Unit Tests
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ This service handles the following NATS subjects for inter-service communication

This service publishes the following NATS events:

#### Project Data Events

- `lfx.index.project`: Published when a project is created, updated, or deleted. Contains the project base data and tags for indexing.
- `lfx.index.project_settings`: Published when project settings are created or updated. Contains the project settings data and tags for indexing.
- `lfx.projects-api.project_settings.updated`: Published when project settings are updated. Contains both the old and new settings to allow downstream services to react to changes. Message format:

```json
Expand All @@ -45,6 +49,43 @@ This service publishes the following NATS events:
}
```

#### Access Control Events

This service uses the generic FGA sync handlers for managing fine-grained access control. All access control messages use the `GenericFGAMessage` envelope format:

- `lfx.fga-sync.update_access`: Published when project access permissions are updated. This is a full sync operation - any relations not included will be removed. Message format:

```json
{
"object_type": "project",
"operation": "update_access",
"data": {
"uid": "project-uid",
"public": true,
"relations": {
"writer": ["username1", "username2"],
"auditor": ["username3"],
"meeting_coordinator": ["username4"]
},
"references": {
"parent": ["project:parent-uid"]
}
}
}
```

- `lfx.fga-sync.delete_access`: Published when a project is deleted. Removes all access control tuples for the project. Message format:

```json
{
"object_type": "project",
"operation": "delete_access",
"data": {
"uid": "project-uid"
}
}
```

### Project Tags

The LFX v2 Project Service generates a set of tags for projects and project settings that are sent to the indexer-service. These tags enable searchability and discoverability of projects through OpenSearch.
Expand Down
2 changes: 1 addition & 1 deletion cmd/project-api/service_endpoint_project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ func TestCreateProject(t *testing.T) {
mockRepo.On("CreateProject", mock.Anything, mock.AnythingOfType("*models.ProjectBase"), mock.AnythingOfType("*models.ProjectSettings")).Return(nil)
// Mock message sending
mockMsg.On("SendIndexerMessage", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("models.ProjectIndexerMessage"), mock.AnythingOfType("bool")).Return(nil)
mockMsg.On("SendAccessMessage", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("models.ProjectAccessMessage"), mock.AnythingOfType("bool")).Return(nil)
mockMsg.On("SendAccessMessage", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("models.GenericFGAMessage"), mock.AnythingOfType("bool")).Return(nil)
mockMsg.On("SendIndexerMessage", mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("models.ProjectSettingsIndexerMessage"), mock.AnythingOfType("bool")).Return(nil)
},
expectedError: false,
Expand Down
32 changes: 20 additions & 12 deletions internal/domain/models/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,28 @@ type IndexerMessageEnvelope struct {
Tags []string `json:"tags"`
}

// ProjectAccessData is the schema for the data in the message sent to the fga-sync service.
// These are the fields that the fga-sync service needs in order to update the OpenFGA permissions.
type ProjectAccessData struct {
UID string `json:"uid"`
Public bool `json:"public"`
ParentUID string `json:"parent_uid"`
Writers []string `json:"writers"`
Auditors []string `json:"auditors"`
MeetingCoordinators []string `json:"meeting_coordinators"`
// GenericFGAMessage is the envelope for all FGA sync operations.
// It uses the generic, resource-agnostic FGA Sync handlers.
type GenericFGAMessage struct {
ObjectType string `json:"object_type"` // Resource type (e.g., "project", "committee", "meeting")
Operation string `json:"operation"` // Operation name (e.g., "update_access", "delete_access")
Data any `json:"data"` // Operation-specific payload
}

// ProjectAccessMessage is a type-safe NATS message for project access control operations.
type ProjectAccessMessage struct {
Data ProjectAccessData `json:"data"`
// UpdateAccessData is the data payload for update_access operations.
// This is a full sync operation - any relations not included will be removed.
type UpdateAccessData struct {
UID string `json:"uid"` // Unique identifier for the resource
Public bool `json:"public"` // If true, adds user:* as viewer
Relations map[string][]string `json:"relations,omitempty"` // Map of relation names to arrays of usernames
References map[string][]string `json:"references,omitempty"` // Map of relation names to arrays of object UIDs
ExcludeRelations []string `json:"exclude_relations,omitempty"` // Relations managed elsewhere
}

// DeleteAccessData is the data payload for delete_access operations.
// Deletes all access control tuples for a resource.
type DeleteAccessData struct {
UID string `json:"uid"` // Unique identifier for the resource to delete
}

// ProjectSettingsUpdatedMessage is a NATS message published when project settings are updated.
Expand Down
131 changes: 112 additions & 19 deletions internal/domain/models/message_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,31 +119,63 @@ func TestProjectSettingsIndexerMessage(t *testing.T) {
}
}

func TestProjectAccessMessage(t *testing.T) {
func TestGenericFGAMessage(t *testing.T) {
tests := []struct {
name string
message ProjectAccessMessage
verify func(t *testing.T, msg ProjectAccessMessage)
message GenericFGAMessage
verify func(t *testing.T, msg GenericFGAMessage)
}{
{
name: "project access message with all fields",
message: ProjectAccessMessage{
Data: ProjectAccessData{
UID: "access-123",
Public: true,
ParentUID: "parent-456",
Writers: []string{"user1", "user2"},
Auditors: []string{"auditor1"},
MeetingCoordinators: []string{"coordinator1"},
name: "generic FGA message for update_access",
message: GenericFGAMessage{
ObjectType: "project",
Operation: "update_access",
Data: UpdateAccessData{
UID: "project-123",
Public: true,
Relations: map[string][]string{
"writer": {"user1", "user2"},
"auditor": {"auditor1"},
"meeting_coordinator": {"coordinator1"},
},
References: map[string][]string{
"parent": {"project:parent-456"},
},
},
},
verify: func(t *testing.T, msg ProjectAccessMessage) {
assert.Equal(t, "access-123", msg.Data.UID)
assert.True(t, msg.Data.Public)
assert.Equal(t, "parent-456", msg.Data.ParentUID)
assert.Len(t, msg.Data.Writers, 2)
assert.Len(t, msg.Data.Auditors, 1)
assert.Len(t, msg.Data.MeetingCoordinators, 1)
verify: func(t *testing.T, msg GenericFGAMessage) {
assert.Equal(t, "project", msg.ObjectType)
assert.Equal(t, "update_access", msg.Operation)

data, ok := msg.Data.(UpdateAccessData)
assert.True(t, ok)
assert.Equal(t, "project-123", data.UID)
assert.True(t, data.Public)
assert.Len(t, data.Relations, 3)
assert.Len(t, data.Relations["writer"], 2)
assert.Len(t, data.Relations["auditor"], 1)
assert.Len(t, data.Relations["meeting_coordinator"], 1)
assert.Len(t, data.References, 1)
assert.Len(t, data.References["parent"], 1)
assert.Equal(t, "project:parent-456", data.References["parent"][0])
},
},
{
name: "generic FGA message for delete_access",
message: GenericFGAMessage{
ObjectType: "project",
Operation: "delete_access",
Data: DeleteAccessData{
UID: "project-789",
},
},
verify: func(t *testing.T, msg GenericFGAMessage) {
assert.Equal(t, "project", msg.ObjectType)
assert.Equal(t, "delete_access", msg.Operation)

data, ok := msg.Data.(DeleteAccessData)
assert.True(t, ok)
assert.Equal(t, "project-789", data.UID)
},
},
}
Expand All @@ -155,6 +187,67 @@ func TestProjectAccessMessage(t *testing.T) {
}
}

func TestUpdateAccessData(t *testing.T) {
tests := []struct {
name string
data UpdateAccessData
verify func(t *testing.T, data UpdateAccessData)
}{
{
name: "update access data with all fields",
data: UpdateAccessData{
UID: "project-123",
Public: true,
Relations: map[string][]string{
"writer": {"user1", "user2"},
"auditor": {"user3"},
},
References: map[string][]string{
"parent": {"project:parent-456"},
},
ExcludeRelations: []string{"custom_relation"},
},
verify: func(t *testing.T, data UpdateAccessData) {
assert.Equal(t, "project-123", data.UID)
assert.True(t, data.Public)
assert.Len(t, data.Relations, 2)
assert.Len(t, data.References, 1)
assert.Len(t, data.ExcludeRelations, 1)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.verify(t, tt.data)
})
}
}

func TestDeleteAccessData(t *testing.T) {
tests := []struct {
name string
data DeleteAccessData
verify func(t *testing.T, data DeleteAccessData)
}{
{
name: "delete access data",
data: DeleteAccessData{
UID: "project-456",
},
verify: func(t *testing.T, data DeleteAccessData) {
assert.Equal(t, "project-456", data.UID)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.verify(t, tt.data)
})
}
}

func TestIndexerMessageEnvelope(t *testing.T) {
tests := []struct {
name string
Expand Down
14 changes: 5 additions & 9 deletions internal/infrastructure/nats/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,20 +151,16 @@ func (m *MessageBuilder) SendIndexerMessage(ctx context.Context, subject string,
}
}

// SendAccessMessage sends access control messages to NATS.
// SendAccessMessage sends access control messages to NATS using the generic FGA sync format.
func (m *MessageBuilder) SendAccessMessage(ctx context.Context, subject string, message interface{}, sync bool) error {
switch msg := message.(type) {
case models.ProjectAccessMessage:
dataBytes, err := json.Marshal(msg.Data)
case models.GenericFGAMessage:
messageBytes, err := json.Marshal(msg)
if err != nil {
slog.ErrorContext(ctx, "error marshalling access message data into JSON", constants.ErrKey, err)
slog.ErrorContext(ctx, "error marshalling FGA message into JSON", constants.ErrKey, err)
return err
}
return m.sendMessage(ctx, subject, dataBytes, sync)

case string:
// For delete operations, the message is just the UID string
return m.sendMessage(ctx, subject, []byte(msg), sync)
return m.sendMessage(ctx, subject, messageBytes, sync)

default:
slog.ErrorContext(ctx, "unsupported access message type", "type", fmt.Sprintf("%T", message))
Expand Down
Loading