diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b242572e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "githubPullRequests.ignoredPullRequestBranches": [ + "main" + ] +} \ No newline at end of file diff --git a/comid/comid.go b/comid/comid.go index 545c86c6..70db22b3 100644 --- a/comid/comid.go +++ b/comid/comid.go @@ -242,6 +242,21 @@ func (o *Comid) AddDevIdentityKey(val *KeyTriple) *Comid { return o } +// AddMembershipTriple adds the supplied membership triple to the +// membership-triples list of the target Comid. +func (o *Comid) AddMembershipTriple(val *MembershipTriple) *Comid { + if o != nil { + if o.Triples.MembershipTriples == nil { + o.Triples.MembershipTriples = NewMembershipTriples() + } + + if o.Triples.AddMembershipTriple(val) == nil { + return nil + } + } + return o +} + // AddCondEndorseSeries adds the supplied conditional series triple to the // conditional series triple list of the target Comid. func (o *Comid) AddCondEndorseSeries(val *CondEndorseSeriesTriple) *Comid { diff --git a/comid/extensions.go b/comid/extensions.go index 584d3337..143f5993 100644 --- a/comid/extensions.go +++ b/comid/extensions.go @@ -18,6 +18,8 @@ const ( ExtCondEndorseSeriesValueFlags extensions.Point = "CondEndorseSeriesValueFlags" ExtMval extensions.Point = "Mval" ExtFlags extensions.Point = "Flags" + ExtMembershipTriple extensions.Point = "MembershipTriple" + ExtMemberVal extensions.Point = "MemberVal" ) type IComidConstrainer interface { diff --git a/comid/membership.go b/comid/membership.go new file mode 100644 index 00000000..129e9a54 --- /dev/null +++ b/comid/membership.go @@ -0,0 +1,130 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "fmt" + + "github.com/veraison/corim/extensions" +) + +// Membership represents a membership record that associates an identifier with membership information. +// It contains a key identifying the membership target and a value containing the membership details. +type Membership struct { + Key *Mkey `cbor:"0,keyasint,omitempty" json:"key,omitempty"` + Val MemberVal `cbor:"1,keyasint" json:"value"` +} + +// NewMembership creates a new Membership with the specified key type and value. +func NewMembership(val any, typ string) (*Membership, error) { + keyFactory, ok := mkeyValueRegister[typ] + if !ok { + return nil, fmt.Errorf("unknown Mkey type: %s", typ) + } + + key, err := keyFactory(val) + if err != nil { + return nil, fmt.Errorf("invalid key: %w", err) + } + + if err = key.Valid(); err != nil { + return nil, fmt.Errorf("invalid key: %w", err) + } + + var ret Membership + ret.Key = key + + return &ret, nil +} + +// MustNewMembership is like NewMembership but panics on error. +func MustNewMembership(val any, typ string) *Membership { + ret, err := NewMembership(val, typ) + if err != nil { + panic(err) + } + return ret +} + +// MustNewUUIDMembership creates a new Membership with a UUID key. +func MustNewUUIDMembership(uuid UUID) *Membership { + return MustNewMembership(uuid, "uuid") +} + +// MustNewUintMembership creates a new Membership with a uint key. +func MustNewUintMembership(u uint64) *Membership { + return MustNewMembership(u, UintType) +} + +// SetValue sets the membership value. +func (o *Membership) SetValue(val *MemberVal) *Membership { + if o != nil { + o.Val = *val + } + return o +} + +func (o *Membership) RegisterExtensions(exts extensions.Map) error { + return o.Val.RegisterExtensions(exts) +} + +func (o *Membership) GetExtensions() extensions.IMapValue { + return o.Val.GetExtensions() +} + +// Valid validates the Membership. +func (o *Membership) Valid() error { + if o.Key != nil { + if err := o.Key.Valid(); err != nil { + return fmt.Errorf("invalid measurement key: %w", err) + } + } + + return o.Val.Valid() +} + +// Memberships is a container for Membership instances and their extensions. +// It is a thin wrapper around extensions.Collection. +type Memberships extensions.Collection[Membership, *Membership] + +func NewMemberships() *Memberships { + return (*Memberships)(extensions.NewCollection[Membership]()) +} + +func (o *Memberships) RegisterExtensions(exts extensions.Map) error { + return (*extensions.Collection[Membership, *Membership])(o).RegisterExtensions(exts) +} + +func (o *Memberships) GetExtensions() extensions.IMapValue { + return (*extensions.Collection[Membership, *Membership])(o).GetExtensions() +} + +func (o *Memberships) Valid() error { + return (*extensions.Collection[Membership, *Membership])(o).Valid() +} + +func (o *Memberships) IsEmpty() bool { + return (*extensions.Collection[Membership, *Membership])(o).IsEmpty() +} + +func (o *Memberships) Add(val *Membership) *Memberships { + ret := (*extensions.Collection[Membership, *Membership])(o).Add(val) + return (*Memberships)(ret) +} + +func (o *Memberships) MarshalCBOR() ([]byte, error) { + return (*extensions.Collection[Membership, *Membership])(o).MarshalCBOR() +} + +func (o *Memberships) UnmarshalCBOR(data []byte) error { + return (*extensions.Collection[Membership, *Membership])(o).UnmarshalCBOR(data) +} + +func (o *Memberships) MarshalJSON() ([]byte, error) { + return (*extensions.Collection[Membership, *Membership])(o).MarshalJSON() +} + +func (o *Memberships) UnmarshalJSON(data []byte) error { + return (*extensions.Collection[Membership, *Membership])(o).UnmarshalJSON(data) +} diff --git a/comid/membership_example_test.go b/comid/membership_example_test.go new file mode 100644 index 00000000..81705fb2 --- /dev/null +++ b/comid/membership_example_test.go @@ -0,0 +1,232 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Example_membershipTriple() { + // Create a new Comid + comid := NewComid(). + SetLanguage("en-US"). + SetTagIdentity("membership-example", 1). + AddEntity("ACME Corp", &TestRegID, RoleCreator, RoleTagCreator) + + // Create membership information for an administrator + adminMember := MemberVal{} + adminMember.SetGroupID("admin-group"). + SetGroupName("Administrator Group"). + SetRole("admin"). + SetStatus("active"). + SetPermissions([]string{"read", "write", "admin"}). + SetOrganizationID("acme-corp") + + // Create a membership keyed by UUID + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&adminMember) + + // Create a membership triple that associates an environment with memberships + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("ACME Corp"). + SetModel("Secure Device v1.0"). + SetLayer(1), + Instance: MustNewUEIDInstance(TestUEID), + }, + Memberships: *NewMemberships().Add(membership), + } + + // Add the membership triple to the Comid + comid.AddMembershipTriple(triple) + + // Validate the comid + err := comid.Valid() + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + // Convert to JSON for demonstration + jsonData, err := comid.ToJSON() + if err != nil { + fmt.Printf("Error converting to JSON: %v\n", err) + return + } + + fmt.Printf("Successfully created Comid with MembershipTriple: %d bytes\n", len(jsonData)) + fmt.Println("MembershipTriple includes:") + fmt.Println("- Environment with class and instance") + fmt.Println("- Membership with group, role, and permissions") + + // Output: + // Successfully created Comid with MembershipTriple: 669 bytes + // MembershipTriple includes: + // - Environment with class and instance + // - Membership with group, role, and permissions +} + +func Example_membershipTriple_multipleMembers() { + // Create a new Comid for multiple memberships + comid := NewComid(). + SetLanguage("en-US"). + SetTagIdentity("multi-membership-example", 1). + AddEntity("ACME Corp", &TestRegID, RoleCreator, RoleTagCreator) + + // Create different membership types + adminMember := MemberVal{} + adminMember.SetGroupID("admin-group"). + SetRole("admin"). + SetStatus("active"). + SetPermissions([]string{"read", "write", "admin"}) + + userMember := MemberVal{} + userMember.SetGroupID("user-group"). + SetRole("user"). + SetStatus("active"). + SetPermissions([]string{"read"}) + + // Create memberships with different key types + adminMembership := MustNewUUIDMembership(TestUUID) + adminMembership.SetValue(&adminMember) + + userMembership := MustNewUUIDMembership(TestUUID) + userMembership.SetValue(&userMember) + + // Create membership collection + memberships := NewMemberships(). + Add(adminMembership). + Add(userMembership) + + // Create a membership triple + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("ACME Corp"). + SetModel("Multi-User Device"), + }, + Memberships: *memberships, + } + + // Add to comid + comid.AddMembershipTriple(triple) + + // Validate + err := comid.Valid() + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("Created Comid with %d memberships\n", len(memberships.Values)) + + // Output: + // Created Comid with 2 memberships +} + +func TestExample_membershipTriple(t *testing.T) { + // This test ensures the example function works correctly + Example_membershipTriple() +} + +func TestExample_membershipTriple_multipleMembers(t *testing.T) { + // This test ensures the multiple members example works correctly + Example_membershipTriple_multipleMembers() +} + +func Test_membershipTriple_RealWorldScenario(t *testing.T) { + // Test a more complex real-world scenario + comid := NewComid(). + SetLanguage("en-US"). + SetTagIdentity("enterprise-device-membership", 1). + AddEntity("Enterprise Corp", &TestRegID, RoleCreator, RoleTagCreator) + + // Device administrator + deviceAdmin := MemberVal{} + deviceAdmin.SetGroupID("device-admin"). + SetGroupName("Device Administrators"). + SetRole("device-admin"). + SetStatus("active"). + SetPermissions([]string{"configure", "monitor", "update", "reset"}). + SetOrganizationID("enterprise-corp"). + SetName("Device Admin Role") + + // Security officer + securityOfficer := MemberVal{} + securityOfficer.SetGroupID("security-team"). + SetGroupName("Security Officers"). + SetRole("security-officer"). + SetStatus("active"). + SetPermissions([]string{"audit", "monitor", "investigate"}). + SetOrganizationID("enterprise-corp"). + SetName("Security Officer Role") + + // Regular user + regularUser := MemberVal{} + regularUser.SetGroupID("users"). + SetGroupName("Regular Users"). + SetRole("user"). + SetStatus("active"). + SetPermissions([]string{"use", "view-status"}). + SetOrganizationID("enterprise-corp"). + SetName("Regular User Role") + + // Create memberships + adminMembership := MustNewUUIDMembership(TestUUID) + adminMembership.SetValue(&deviceAdmin) + + securityMembership := MustNewUintMembership(12345) + securityMembership.SetValue(&securityOfficer) + + userMembership := MustNewUintMembership(67890) + userMembership.SetValue(®ularUser) + + // Create the environment (enterprise device) + environment := Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Enterprise Corp"). + SetModel("Secure Workstation Pro"). + SetLayer(1), + Instance: MustNewUEIDInstance(TestUEID), + } + + // Create membership triple + triple := &MembershipTriple{ + Environment: environment, + Memberships: *NewMemberships(). + Add(adminMembership). + Add(securityMembership). + Add(userMembership), + } + + // Add to comid + comid.AddMembershipTriple(triple) + + // Validate + err := comid.Valid() + require.NoError(t, err) + + // Test serialization + cborData, err := comid.ToCBOR() + require.NoError(t, err) + assert.NotEmpty(t, cborData) + + jsonData, err := comid.ToJSON() + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Verify content + assert.Contains(t, string(jsonData), "membership-triples") + assert.Contains(t, string(jsonData), "device-admin") + assert.Contains(t, string(jsonData), "security-officer") + assert.Contains(t, string(jsonData), "Enterprise Corp") + + fmt.Printf("Enterprise membership scenario: %d bytes CBOR, %d bytes JSON\n", + len(cborData), len(jsonData)) +} diff --git a/comid/membership_integration_test.go b/comid/membership_integration_test.go new file mode 100644 index 00000000..b3d44a85 --- /dev/null +++ b/comid/membership_integration_test.go @@ -0,0 +1,195 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestComid_AddMembershipTriple_Success(t *testing.T) { + comid := NewComid() + comid.SetTagIdentity("test-comid", 0) + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin").SetStatus("active") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + result := comid.AddMembershipTriple(triple) + assert.Equal(t, comid, result) + assert.NotNil(t, comid.Triples.MembershipTriples) + assert.False(t, comid.Triples.MembershipTriples.IsEmpty()) +} + +func TestComid_AddMembershipTriple_Validation(t *testing.T) { + comid := NewComid() + comid.SetTagIdentity("test-comid", 0) + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + comid.AddMembershipTriple(triple) + + err := comid.Valid() + assert.NoError(t, err) +} + +func TestTriples_AddMembershipTriple_Success(t *testing.T) { + triples := &Triples{} + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + result := triples.AddMembershipTriple(triple) + assert.Equal(t, triples, result) + assert.NotNil(t, triples.MembershipTriples) + assert.False(t, triples.MembershipTriples.IsEmpty()) +} + +func TestTriples_Valid_WithMembershipTriples(t *testing.T) { + triples := &Triples{} + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + triples.AddMembershipTriple(triple) + + err := triples.Valid() + assert.NoError(t, err) +} + +func TestTriples_CBOR_RoundTrip_WithMembershipTriples(t *testing.T) { + original := &Triples{} + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + original.AddMembershipTriple(triple) + + data, err := original.MarshalCBOR() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded Triples + err = decoded.UnmarshalCBOR(data) + require.NoError(t, err) + + err = decoded.Valid() + assert.NoError(t, err) + + // Verify that the membership triple was preserved + assert.NotNil(t, decoded.MembershipTriples) + assert.False(t, decoded.MembershipTriples.IsEmpty()) +} + +func TestComid_Full_Example_WithMembershipTriple(t *testing.T) { + comid := NewComid(). + SetLanguage("en-US"). + SetTagIdentity("membership-test-comid", 1). + AddEntity("Test Corp", &TestRegID, RoleCreator, RoleTagCreator) + + // Create membership information + memberVal := MemberVal{} + memberVal.SetGroupID("admin-group"). + SetGroupName("Administrator Group"). + SetRole("admin"). + SetStatus("active"). + SetPermissions([]string{"read", "write", "admin"}). + SetOrganizationID("test-corp") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"). + SetLayer(1), + Instance: MustNewUEIDInstance(TestUEID), + }, + Memberships: *NewMemberships().Add(membership), + } + + result := comid.AddMembershipTriple(triple) + assert.Equal(t, comid, result) + + // Validate the full comid + err := comid.Valid() + assert.NoError(t, err) + + // Test CBOR serialization + cborData, err := comid.ToCBOR() + require.NoError(t, err) + assert.NotEmpty(t, cborData) + + // Test JSON serialization + jsonData, err := comid.ToJSON() + require.NoError(t, err) + assert.NotEmpty(t, jsonData) + + // Verify that membership triples are included in the JSON + assert.Contains(t, string(jsonData), "membership-triples") +} diff --git a/comid/membership_test.go b/comid/membership_test.go new file mode 100644 index 00000000..075f7cee --- /dev/null +++ b/comid/membership_test.go @@ -0,0 +1,304 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/veraison/corim/extensions" +) + +func TestMemberVal_SettersAndGetters(t *testing.T) { + memberVal := &MemberVal{} + + // Test SetGroupID + result := memberVal.SetGroupID("group-1") + assert.Equal(t, memberVal, result) + assert.Equal(t, "group-1", *memberVal.GroupID) + + // Test SetGroupName + memberVal.SetGroupName("Test Group") + assert.Equal(t, "Test Group", *memberVal.GroupName) + + // Test SetRole + memberVal.SetRole("admin") + assert.Equal(t, "admin", *memberVal.Role) + + // Test SetStatus + memberVal.SetStatus("active") + assert.Equal(t, "active", *memberVal.Status) + + // Test SetPermissions + permissions := []string{"read", "write", "admin"} + memberVal.SetPermissions(permissions) + assert.Equal(t, permissions, *memberVal.Permissions) + + // Test SetOrganizationID + memberVal.SetOrganizationID("org-123") + assert.Equal(t, "org-123", *memberVal.OrganizationID) + + // Test SetUEID + memberVal.SetUEID(TestUEID) + assert.Equal(t, TestUEID, *memberVal.UEID) + + // Test SetUUID + memberVal.SetUUID(TestUUID) + assert.Equal(t, TestUUID, *memberVal.UUID) + + // Test SetName + memberVal.SetName("Test Name") + assert.Equal(t, "Test Name", *memberVal.Name) +} + +func TestMemberVal_Valid_Success(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetGroupID("group-1") + + err := memberVal.Valid() + assert.NoError(t, err) +} + +func TestMemberVal_Valid_EmptyValues(t *testing.T) { + memberVal := MemberVal{} + + err := memberVal.Valid() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no membership value set") +} + +func TestMemberVal_Valid_WithValidUEID(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetUEID(TestUEID) + + err := memberVal.Valid() + assert.NoError(t, err) +} + +func TestMemberVal_Valid_WithValidUUID(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetUUID(TestUUID) + + err := memberVal.Valid() + assert.NoError(t, err) +} + +func TestMemberVal_RegisterExtensions(t *testing.T) { + memberVal := &MemberVal{} + + extMap := extensions.NewMap().Add(ExtMemberVal, &struct{}{}) + err := memberVal.RegisterExtensions(extMap) + assert.NoError(t, err) + + exts := memberVal.GetExtensions() + assert.NotNil(t, exts) +} + +func TestMemberVal_CBOR_RoundTrip(t *testing.T) { + original := MemberVal{} + original.SetGroupID("group-1").SetRole("admin").SetStatus("active") + + data, err := original.MarshalCBOR() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded MemberVal + err = decoded.UnmarshalCBOR(data) + require.NoError(t, err) + + assert.Equal(t, *original.GroupID, *decoded.GroupID) + assert.Equal(t, *original.Role, *decoded.Role) + assert.Equal(t, *original.Status, *decoded.Status) +} + +func TestMemberVal_JSON_RoundTrip(t *testing.T) { + original := MemberVal{} + original.SetGroupID("group-1").SetRole("admin").SetStatus("active") + + data, err := original.MarshalJSON() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded MemberVal + err = decoded.UnmarshalJSON(data) + require.NoError(t, err) + + assert.Equal(t, *original.GroupID, *decoded.GroupID) + assert.Equal(t, *original.Role, *decoded.Role) + assert.Equal(t, *original.Status, *decoded.Status) +} + +func TestMembership_NewMembership_Success(t *testing.T) { + membership, err := NewMembership(TestUUID, "uuid") + require.NoError(t, err) + assert.NotNil(t, membership) + assert.NotNil(t, membership.Key) +} + +func TestMembership_NewMembership_InvalidType(t *testing.T) { + _, err := NewMembership(TestUUID, "invalid-type") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown Mkey type") +} + +func TestMembership_MustNewMembership_Success(t *testing.T) { + membership := MustNewMembership(TestUUID, "uuid") + assert.NotNil(t, membership) + assert.NotNil(t, membership.Key) +} + +func TestMembership_MustNewMembership_Panic(t *testing.T) { + assert.Panics(t, func() { + MustNewMembership(TestUUID, "invalid-type") + }) +} + +func TestMembership_MustNewUUIDMembership(t *testing.T) { + membership := MustNewUUIDMembership(TestUUID) + assert.NotNil(t, membership) + assert.NotNil(t, membership.Key) +} + +func TestMembership_SetValue(t *testing.T) { + membership := MustNewUUIDMembership(TestUUID) + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1") + + result := membership.SetValue(&memberVal) + assert.Equal(t, membership, result) + assert.Equal(t, memberVal, membership.Val) +} + +func TestMembership_Valid_Success(t *testing.T) { + membership := MustNewUUIDMembership(TestUUID) + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1") + membership.SetValue(&memberVal) + + err := membership.Valid() + assert.NoError(t, err) +} + +func TestMembership_Valid_InvalidValue(t *testing.T) { + membership := MustNewUUIDMembership(TestUUID) + + // Empty MemberVal should be invalid + memberVal := MemberVal{} + membership.SetValue(&memberVal) + + err := membership.Valid() + assert.Error(t, err) +} + +func TestMemberships_NewMemberships(t *testing.T) { + memberships := NewMemberships() + assert.NotNil(t, memberships) + assert.True(t, memberships.IsEmpty()) +} + +func TestMemberships_Add_Success(t *testing.T) { + memberships := NewMemberships() + + membership := MustNewUUIDMembership(TestUUID) + memberVal := MemberVal{} + memberVal.SetGroupID("group-1") + membership.SetValue(&memberVal) + + result := memberships.Add(membership) + assert.Equal(t, memberships, result) + assert.False(t, memberships.IsEmpty()) +} + +func TestMemberships_Valid_Success(t *testing.T) { + memberships := NewMemberships() + + membership := MustNewUUIDMembership(TestUUID) + memberVal := MemberVal{} + memberVal.SetGroupID("group-1") + membership.SetValue(&memberVal) + + memberships.Add(membership) + + err := memberships.Valid() + assert.NoError(t, err) +} + +func TestMemberships_Valid_Empty(t *testing.T) { + memberships := NewMemberships() + + err := memberships.Valid() + assert.NoError(t, err) // Empty collection is valid +} + +func TestMemberships_Valid_InvalidMembership(t *testing.T) { + memberships := NewMemberships() + + membership := MustNewUUIDMembership(TestUUID) + // Add membership with empty value (invalid) + memberVal := MemberVal{} + membership.SetValue(&memberVal) + + memberships.Add(membership) + + err := memberships.Valid() + assert.Error(t, err) +} + +func TestMemberships_RegisterExtensions(t *testing.T) { + memberships := NewMemberships() + + extMap := extensions.NewMap().Add(ExtMemberVal, &struct{}{}) + err := memberships.RegisterExtensions(extMap) + assert.NoError(t, err) + + exts := memberships.GetExtensions() + assert.NotNil(t, exts) +} + +func TestMemberships_CBOR_RoundTrip(t *testing.T) { + original := NewMemberships() + + membership := MustNewUUIDMembership(TestUUID) + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + membership.SetValue(&memberVal) + + original.Add(membership) + + data, err := original.MarshalCBOR() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded Memberships + err = decoded.UnmarshalCBOR(data) + require.NoError(t, err) + + err = decoded.Valid() + assert.NoError(t, err) +} + +func TestMemberships_JSON_RoundTrip(t *testing.T) { + original := NewMemberships() + + membership := MustNewUUIDMembership(TestUUID) + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + membership.SetValue(&memberVal) + + original.Add(membership) + + data, err := original.MarshalJSON() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded Memberships + err = decoded.UnmarshalJSON(data) + require.NoError(t, err) + + err = decoded.Valid() + assert.NoError(t, err) +} diff --git a/comid/membership_triple.go b/comid/membership_triple.go new file mode 100644 index 00000000..ed41de26 --- /dev/null +++ b/comid/membership_triple.go @@ -0,0 +1,90 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "errors" + "fmt" + + "github.com/veraison/corim/extensions" +) + +// MembershipTriple relates membership information to a target environment, +// essentially forming a subject-predicate-object triple of "memberships-pertain +// to-environment". This structure is used to represent membership-triple-record +// in the CoRIM spec. +type MembershipTriple struct { + _ struct{} `cbor:",toarray"` + Environment Environment `json:"environment"` + Memberships Memberships `json:"memberships"` +} + +func (o *MembershipTriple) RegisterExtensions(exts extensions.Map) error { + return o.Memberships.RegisterExtensions(exts) +} + +func (o *MembershipTriple) GetExtensions() extensions.IMapValue { + return o.Memberships.GetExtensions() +} + +func (o *MembershipTriple) Valid() error { + if err := o.Environment.Valid(); err != nil { + return fmt.Errorf("environment validation failed: %w", err) + } + + if o.Memberships.IsEmpty() { + return errors.New("memberships validation failed: no membership entries") + } + + if err := o.Memberships.Valid(); err != nil { + return fmt.Errorf("memberships validation failed: %w", err) + } + + return nil +} + +// MembershipTriples is a container for MembershipTriple instances and their extensions. +// It is a thin wrapper around extensions.Collection. +type MembershipTriples extensions.Collection[MembershipTriple, *MembershipTriple] + +func NewMembershipTriples() *MembershipTriples { + return (*MembershipTriples)(extensions.NewCollection[MembershipTriple]()) +} + +func (o *MembershipTriples) RegisterExtensions(exts extensions.Map) error { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).RegisterExtensions(exts) +} + +func (o *MembershipTriples) GetExtensions() extensions.IMapValue { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).GetExtensions() +} + +func (o *MembershipTriples) Valid() error { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).Valid() +} + +func (o *MembershipTriples) IsEmpty() bool { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).IsEmpty() +} + +func (o *MembershipTriples) Add(val *MembershipTriple) *MembershipTriples { + ret := (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).Add(val) + return (*MembershipTriples)(ret) +} + +func (o *MembershipTriples) MarshalCBOR() ([]byte, error) { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).MarshalCBOR() +} + +func (o *MembershipTriples) UnmarshalCBOR(data []byte) error { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).UnmarshalCBOR(data) +} + +func (o *MembershipTriples) MarshalJSON() ([]byte, error) { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).MarshalJSON() +} + +func (o *MembershipTriples) UnmarshalJSON(data []byte) error { + return (*extensions.Collection[MembershipTriple, *MembershipTriple])(o).UnmarshalJSON(data) +} diff --git a/comid/membership_triple_test.go b/comid/membership_triple_test.go new file mode 100644 index 00000000..324d187b --- /dev/null +++ b/comid/membership_triple_test.go @@ -0,0 +1,221 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/veraison/corim/extensions" +) + +func TestMembershipTriple_Valid_Success(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin").SetStatus("active") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + err := triple.Valid() + assert.NoError(t, err) +} + +func TestMembershipTriple_Valid_EmptyEnvironment(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{}, // Empty environment + Memberships: *NewMemberships().Add(membership), + } + + err := triple.Valid() + assert.Error(t, err) + assert.Contains(t, err.Error(), "environment validation failed") +} + +func TestMembershipTriple_Valid_EmptyMemberships(t *testing.T) { + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships(), // Empty memberships + } + + err := triple.Valid() + assert.Error(t, err) + assert.Contains(t, err.Error(), "no membership entries") +} + +func TestMembershipTriple_Extensions(t *testing.T) { + triple := &MembershipTriple{} + + // Test RegisterExtensions + extMap := extensions.NewMap().Add(ExtMemberVal, &struct{}{}) + err := triple.RegisterExtensions(extMap) + assert.NoError(t, err) + + // Test GetExtensions + exts := triple.GetExtensions() + assert.NotNil(t, exts) +} + +func TestMembershipTriples_NewMembershipTriples(t *testing.T) { + triples := NewMembershipTriples() + assert.NotNil(t, triples) + assert.True(t, triples.IsEmpty()) +} + +func TestMembershipTriples_Add_Success(t *testing.T) { + triples := NewMembershipTriples() + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + result := triples.Add(triple) + assert.Equal(t, triples, result) + assert.False(t, triples.IsEmpty()) +} + +func TestMembershipTriples_Valid_Success(t *testing.T) { + triples := NewMembershipTriples() + + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + triple := &MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + } + + triples.Add(triple) + + err := triples.Valid() + assert.NoError(t, err) +} + +func TestMembershipTriples_Valid_Empty(t *testing.T) { + triples := NewMembershipTriples() + + err := triples.Valid() + assert.NoError(t, err) // Empty collection is valid +} + +func TestMembershipTriples_Valid_InvalidTriple(t *testing.T) { + triples := NewMembershipTriples() + + // Add invalid triple with empty environment + triple := &MembershipTriple{ + Environment: Environment{}, // Empty environment + Memberships: *NewMemberships(), + } + + triples.Add(triple) + + err := triples.Valid() + assert.Error(t, err) +} + +func TestMembershipTriples_RegisterExtensions(t *testing.T) { + triples := NewMembershipTriples() + + extMap := extensions.NewMap().Add(ExtMemberVal, &struct{}{}) + err := triples.RegisterExtensions(extMap) + assert.NoError(t, err) + + exts := triples.GetExtensions() + assert.NotNil(t, exts) +} + +func TestMembershipTriples_CBOR_RoundTrip(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + original := NewMembershipTriples() + original.Add(&MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + }) + + data, err := original.MarshalCBOR() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded MembershipTriples + err = decoded.UnmarshalCBOR(data) + require.NoError(t, err) + + err = decoded.Valid() + assert.NoError(t, err) +} + +func TestMembershipTriples_JSON_RoundTrip(t *testing.T) { + memberVal := MemberVal{} + memberVal.SetGroupID("group-1").SetRole("admin") + + membership := MustNewUUIDMembership(TestUUID) + membership.SetValue(&memberVal) + + original := NewMembershipTriples() + original.Add(&MembershipTriple{ + Environment: Environment{ + Class: NewClassUUID(TestUUID). + SetVendor("Test Vendor"). + SetModel("Test Model"), + }, + Memberships: *NewMemberships().Add(membership), + }) + + data, err := original.MarshalJSON() + require.NoError(t, err) + assert.NotEmpty(t, data) + + var decoded MembershipTriples + err = decoded.UnmarshalJSON(data) + require.NoError(t, err) + + err = decoded.Valid() + assert.NoError(t, err) +} diff --git a/comid/memberval.go b/comid/memberval.go new file mode 100644 index 00000000..768ae4c4 --- /dev/null +++ b/comid/memberval.go @@ -0,0 +1,173 @@ +// Copyright 2025 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 + +package comid + +import ( + "fmt" + + "github.com/veraison/corim/encoding" + "github.com/veraison/corim/extensions" + "github.com/veraison/eat" +) + +// MemberVal holds membership-related values for a specific membership record. +// It contains various types of membership information that can be associated +// with an environment. +type MemberVal struct { + GroupID *string `cbor:"0,keyasint,omitempty" json:"group-id,omitempty"` + GroupName *string `cbor:"1,keyasint,omitempty" json:"group-name,omitempty"` + Role *string `cbor:"2,keyasint,omitempty" json:"role,omitempty"` + Status *string `cbor:"3,keyasint,omitempty" json:"status,omitempty"` + Permissions *[]string `cbor:"4,keyasint,omitempty" json:"permissions,omitempty"` + OrganizationID *string `cbor:"5,keyasint,omitempty" json:"organization-id,omitempty"` + UEID *eat.UEID `cbor:"6,keyasint,omitempty" json:"ueid,omitempty"` + UUID *UUID `cbor:"7,keyasint,omitempty" json:"uuid,omitempty"` + Name *string `cbor:"8,keyasint,omitempty" json:"name,omitempty"` + Extensions +} + +// RegisterExtensions registers a struct as a collections of extensions +func (o *MemberVal) RegisterExtensions(exts extensions.Map) error { + for p, v := range exts { + switch p { + case ExtMemberVal: + o.Register(v) + default: + return fmt.Errorf("%w: %q", extensions.ErrUnexpectedPoint, p) + } + } + + return nil +} + +// GetExtensions returns previously registered extension +func (o *MemberVal) GetExtensions() extensions.IMapValue { + return o.IMapValue +} + +// UnmarshalCBOR deserializes from CBOR +func (o *MemberVal) UnmarshalCBOR(data []byte) error { + return encoding.PopulateStructFromCBOR(dm, data, o) +} + +// MarshalCBOR serializes to CBOR +func (o *MemberVal) MarshalCBOR() ([]byte, error) { + return encoding.SerializeStructToCBOR(em, o) +} + +// UnmarshalJSON deserializes from JSON +func (o *MemberVal) UnmarshalJSON(data []byte) error { + return encoding.PopulateStructFromJSON(data, o) +} + +// MarshalJSON serializes to JSON +func (o *MemberVal) MarshalJSON() ([]byte, error) { + return encoding.SerializeStructToJSON(o) +} + +// SetGroupID sets the group identifier for the membership. +func (o *MemberVal) SetGroupID(groupID string) *MemberVal { + if o != nil { + o.GroupID = &groupID + } + return o +} + +// SetGroupName sets the group name for the membership. +func (o *MemberVal) SetGroupName(groupName string) *MemberVal { + if o != nil { + o.GroupName = &groupName + } + return o +} + +// SetRole sets the role for the membership. +func (o *MemberVal) SetRole(role string) *MemberVal { + if o != nil { + o.Role = &role + } + return o +} + +// SetStatus sets the status for the membership. +func (o *MemberVal) SetStatus(status string) *MemberVal { + if o != nil { + o.Status = &status + } + return o +} + +// SetPermissions sets the permissions for the membership. +func (o *MemberVal) SetPermissions(permissions []string) *MemberVal { + if o != nil { + o.Permissions = &permissions + } + return o +} + +// SetOrganizationID sets the organization identifier for the membership. +func (o *MemberVal) SetOrganizationID(orgID string) *MemberVal { + if o != nil { + o.OrganizationID = &orgID + } + return o +} + +// SetUEID sets the UEID for the membership. +func (o *MemberVal) SetUEID(ueid eat.UEID) *MemberVal { + if o != nil { + o.UEID = &ueid + } + return o +} + +// SetUUID sets the UUID for the membership. +func (o *MemberVal) SetUUID(uuid UUID) *MemberVal { + if o != nil { + o.UUID = &uuid + } + return o +} + +// SetName sets the name for the membership. +func (o *MemberVal) SetName(name string) *MemberVal { + if o != nil { + o.Name = &name + } + return o +} + +// Valid returns an error if none of the membership values are set and the Extensions are empty. +func (o *MemberVal) Valid() error { + // Check if no membership values are set + if o.GroupID == nil && + o.GroupName == nil && + o.Role == nil && + o.Status == nil && + o.Permissions == nil && + o.OrganizationID == nil && + o.UEID == nil && + o.UUID == nil && + o.Name == nil && + o.IsEmpty() { + + return fmt.Errorf("no membership value set") + } + + // Validate UEID if set + if o.UEID != nil { + if err := UEID(*o.UEID).Valid(); err != nil { + return fmt.Errorf("UEID validation failed: %w", err) + } + } + + // Validate UUID if set + if o.UUID != nil { + if err := o.UUID.Valid(); err != nil { + return fmt.Errorf("UUID validation failed: %w", err) + } + } + + return nil +} diff --git a/comid/triples.go b/comid/triples.go index 259eecde..eb76643a 100644 --- a/comid/triples.go +++ b/comid/triples.go @@ -15,6 +15,7 @@ type Triples struct { EndorsedValues *ValueTriples `cbor:"1,keyasint,omitempty" json:"endorsed-values,omitempty"` DevIdentityKeys *KeyTriples `cbor:"2,keyasint,omitempty" json:"dev-identity-keys,omitempty"` AttestVerifKeys *KeyTriples `cbor:"3,keyasint,omitempty" json:"attester-verification-keys,omitempty"` + MembershipTriples *MembershipTriples `cbor:"4,keyasint,omitempty" json:"membership-triples,omitempty"` CondEndorseSeries *CondEndorseSeriesTriples `cbor:"8,keyasint,omitempty" json:"conditional-endorsement-series,omitempty"` Extensions } @@ -24,6 +25,7 @@ func (o *Triples) RegisterExtensions(exts extensions.Map) error { refValExts := extensions.NewMap() endValExts := extensions.NewMap() conSeriesExts := extensions.NewMap() + membershipExts := extensions.NewMap() for p, v := range exts { switch p { @@ -41,6 +43,8 @@ func (o *Triples) RegisterExtensions(exts extensions.Map) error { conSeriesExts[ExtMval] = v case ExtCondEndorseSeriesValueFlags: conSeriesExts[ExtFlags] = v + case ExtMembershipTriple: + membershipExts[ExtMemberVal] = v default: return fmt.Errorf("%w: %q", extensions.ErrUnexpectedPoint, p) } @@ -76,6 +80,16 @@ func (o *Triples) RegisterExtensions(exts extensions.Map) error { } } + if len(membershipExts) != 0 { + if o.MembershipTriples == nil { + o.MembershipTriples = NewMembershipTriples() + } + + if err := o.MembershipTriples.RegisterExtensions(membershipExts); err != nil { + return err + } + } + return nil } @@ -105,6 +119,10 @@ func (o Triples) MarshalCBOR() ([]byte, error) { o.EndorsedValues = nil } + if o.MembershipTriples != nil && o.MembershipTriples.IsEmpty() { + o.MembershipTriples = nil + } + if o.CondEndorseSeries != nil && o.CondEndorseSeries.IsEmpty() { o.CondEndorseSeries = nil } @@ -147,6 +165,7 @@ func (o Triples) Valid() error { (o.EndorsedValues == nil || o.EndorsedValues.IsEmpty()) && (o.AttestVerifKeys == nil || len(*o.AttestVerifKeys) == 0) && (o.DevIdentityKeys == nil || len(*o.DevIdentityKeys) == 0) && + (o.MembershipTriples == nil || o.MembershipTriples.IsEmpty()) && (o.CondEndorseSeries == nil || o.CondEndorseSeries.IsEmpty()) { return fmt.Errorf("triples struct must not be empty") } @@ -179,6 +198,12 @@ func (o Triples) Valid() error { } } + if o.MembershipTriples != nil { + if err := o.MembershipTriples.Valid(); err != nil { + return fmt.Errorf("membership triples: %w", err) + } + } + if o.CondEndorseSeries != nil { if err := o.CondEndorseSeries.Valid(); err != nil { return fmt.Errorf("conditional series: %w", err) @@ -228,6 +253,18 @@ func (o *Triples) AddDevIdentityKey(val *KeyTriple) *Triples { return o } +func (o *Triples) AddMembershipTriple(val *MembershipTriple) *Triples { + if o != nil { + if o.MembershipTriples == nil { + o.MembershipTriples = new(MembershipTriples) + } + + o.MembershipTriples.Add(val) + } + + return o +} + // nolint:gocritic func (o *Triples) AddCondEndorseSeries(val *CondEndorseSeriesTriple) *Triples { if o != nil {