diff --git a/asserts/asserts.go b/asserts/asserts.go index 42954a354c2..b0867e107d4 100644 --- a/asserts/asserts.go +++ b/asserts/asserts.go @@ -153,6 +153,7 @@ var ( DeviceSessionRequestType = &AssertionType{"device-session-request", []string{"brand-id", "model", "serial"}, nil, assembleDeviceSessionRequest, noAuthority} SerialRequestType = &AssertionType{"serial-request", nil, nil, assembleSerialRequest, noAuthority} AccountKeyRequestType = &AssertionType{"account-key-request", []string{"public-key-sha3-384"}, nil, assembleAccountKeyRequest, noAuthority} + RegistryControlType = &AssertionType{"registry-control", []string{"brand-id", "model", "serial", "operator-id"}, nil, assembleRegistryControl, noAuthority} ) var typeRegistry = map[string]*AssertionType{ @@ -178,6 +179,7 @@ var typeRegistry = map[string]*AssertionType{ DeviceSessionRequestType.Name: DeviceSessionRequestType, SerialRequestType.Name: SerialRequestType, AccountKeyRequestType.Name: AccountKeyRequestType, + RegistryControlType.Name: RegistryControlType, } // Type returns the AssertionType with name or nil diff --git a/asserts/asserts_test.go b/asserts/asserts_test.go index 2fe009946e3..abb1a8e3483 100644 --- a/asserts/asserts_test.go +++ b/asserts/asserts_test.go @@ -56,6 +56,7 @@ func (as *assertsSuite) TestTypeNames(c *C) { "model", "preseed", "registry", + "registry-control", "repair", "serial", "serial-request", @@ -1207,7 +1208,10 @@ func (as *assertsSuite) TestWithAuthority(c *C) { "validation-set", "repair", } - c.Check(withAuthority, HasLen, asserts.NumAssertionType-3) // excluding device-session-request, serial-request, account-key-request + + // excluding device-session-request, serial-request, account-key-request, registry-control + c.Check(withAuthority, HasLen, asserts.NumAssertionType-4) + for _, name := range withAuthority { typ := asserts.Type(name) _, err := asserts.AssembleAndSignInTest(typ, nil, []byte("{}"), testPrivKey1) diff --git a/asserts/header_checks.go b/asserts/header_checks.go index c9b3915730e..81c30a029d1 100644 --- a/asserts/header_checks.go +++ b/asserts/header_checks.go @@ -322,3 +322,20 @@ func checkMapWhat(m map[string]interface{}, name, what string) (map[string]inter } return mv, nil } + +func checkList(headers map[string]interface{}, name string) ([]interface{}, error) { + return checkListWhat(headers, name, "header") +} + +func checkListWhat(headers map[string]interface{}, name, what string) ([]interface{}, error) { + value, ok := headers[name] + if !ok { + return nil, nil + } + + list, ok := value.([]interface{}) + if !ok { + return nil, fmt.Errorf("%q %s must be a list", name, what) + } + return list, nil +} diff --git a/asserts/registry.go b/asserts/registry_asserts.go similarity index 59% rename from asserts/registry.go rename to asserts/registry_asserts.go index 6b776d533e5..2e069455933 100644 --- a/asserts/registry.go +++ b/asserts/registry_asserts.go @@ -108,3 +108,79 @@ func assembleRegistry(assert assertionBase) (Assertion, error) { timestamp: timestamp, }, nil } + +// RegistryControl holds a registry-control assertion, which holds a list of +// registry views delegated by the device to an operator. +type RegistryControl struct { + assertionBase + + registryControl *registry.RegistryControl +} + +// BrandID returns the brand identifier of the device. +func (rgCtrl *RegistryControl) BrandID() string { + return rgCtrl.HeaderString("brand-id") +} + +// Model returns the model name identifier of the device. +func (rgCtrl *RegistryControl) Model() string { + return rgCtrl.HeaderString("model") +} + +// Serial returns the serial identifier of the device, together with +// brand id and model they form the unique identifier of the device. +func (rgCtrl *RegistryControl) Serial() string { + return rgCtrl.HeaderString("serial") +} + +// OperatorID returns the identifier of the account the device +// has delegated registry control to. +func (rgCtrl *RegistryControl) OperatorID() string { + return rgCtrl.registryControl.OperatorID +} + +// RegistryControl... +func (rgCtrl *RegistryControl) RegistryControl() *registry.RegistryControl { + return rgCtrl.registryControl +} + +// TODO: Confirm that the brand-id, model, & serial match the device's serial assertion +func assembleRegistryControl(assert assertionBase) (Assertion, error) { + _, err := checkNotEmptyString(assert.headers, "brand-id") + if err != nil { + return nil, err + } + + _, err = checkModel(assert.headers) + if err != nil { + return nil, err + } + + _, err = checkNotEmptyString(assert.headers, "serial") + if err != nil { + return nil, err + } + + operatorID, err := checkNotEmptyString(assert.headers, "operator-id") + if err != nil { + return nil, err + } + + views, err := checkList(assert.headers, "views") + if err != nil { + return nil, err + } + if views == nil { + return nil, fmt.Errorf(`"views" stanza is mandatory`) + } + + rgCtrl, err := registry.NewRegistryControl(operatorID, views) + if err != nil { + return nil, err + } + + return &RegistryControl{ + assertionBase: assert, + registryControl: rgCtrl, + }, nil +} diff --git a/asserts/registry_test.go b/asserts/registry_asserts_test.go similarity index 66% rename from asserts/registry_test.go rename to asserts/registry_asserts_test.go index 7f4a2178ed7..f24d9032bf6 100644 --- a/asserts/registry_test.go +++ b/asserts/registry_asserts_test.go @@ -33,7 +33,10 @@ type registrySuite struct { tsLine string } -var _ = Suite(®istrySuite{}) +var ( + _ = Suite(®istrySuite{}) + _ = Suite(®istryControlSuite{}) +) func (s *registrySuite) SetUpSuite(c *C) { s.ts = time.Now().Truncate(time.Second).UTC() @@ -199,3 +202,87 @@ func (s *registrySuite) TestAssembleAndSignChecksSchemaFormatFail(c *C) { _, err := asserts.AssembleAndSignInTest(asserts.RegistryType, headers, []byte(schema), testPrivKey0) c.Assert(err, ErrorMatches, `assertion registry: JSON in body must be indented with 2 spaces and sort object entries by key`) } + +type registryControlSuite struct{} + +const ( + registryControlExample = `type: registry-control +brand-id: generic +model: generic-classic +serial: 03961d5d-26e5-443f-838d-6db046126bea +operator-id: f22PSauKuNkwQTM9Wz67ZCjNACuSjjhN +views: + - + name: canonical/network/control-device + - + name: canonical/network/observe-device + - + name: canonical/network/control-interfaces + - + name: canonical/network/observe-interfaces +sign-key-sha3-384: t9yuKGLyiezBq_PXMJZsGdkTukmL7MgrgqXAlxxiZF4TYryOjZcy48nnjDmEHQDp + +AXNpZw==` +) + +func (s *registryControlSuite) TestDecodeOK(c *C) { + encoded := registryControlExample + + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + c.Check(a, NotNil) + c.Check(a.Type(), Equals, asserts.RegistryControlType) + + rgCtrlA := a.(*asserts.RegistryControl) + c.Check(rgCtrlA.AuthorityID(), Equals, "") + c.Check(rgCtrlA.BrandID(), Equals, "generic") + c.Check(rgCtrlA.Model(), Equals, "generic-classic") + c.Check(rgCtrlA.Serial(), Equals, "03961d5d-26e5-443f-838d-6db046126bea") + c.Check(rgCtrlA.OperatorID(), Equals, "f22PSauKuNkwQTM9Wz67ZCjNACuSjjhN") + + rgCtrl := rgCtrlA.RegistryControl() + c.Assert(rgCtrl, NotNil) + networkRegistry := rgCtrl.Registries["canonical/network"] + c.Assert(networkRegistry, NotNil) + + c.Check(len(networkRegistry.Views), Equals, 4) + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-device"), Equals, true) + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-interfaces"), Equals, true) + c.Check(rgCtrl.IsDelegated("canonical", "network", "observe-device"), Equals, true) + c.Check(rgCtrl.IsDelegated("canonical", "network", "observe-interfaces"), Equals, true) + + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-vpn"), Equals, false) + + rgCtrl.Delegate("canonical", "network", "control-vpn") + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-vpn"), Equals, true) + + rgCtrl.Revoke("canonical", "network", "control-vpn") + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-vpn"), Equals, false) +} + +func (s *registryControlSuite) TestDecodeInvalid(c *C) { + encoded := registryControlExample + const validationSetErrPrefix = "assertion registry-control: " + + viewsStanza := encoded[strings.Index(encoded, "views:") : strings.Index(encoded, "sign-key-sha3-384:")-1] + + invalidTests := []struct{ original, invalid, expectedErr string }{ + {"brand-id: generic\n", "", `"brand-id" header is mandatory`}, + {"model: generic-classic\n", "", `"model" header is mandatory`}, + {"serial: 03961d5d-26e5-443f-838d-6db046126bea\n", "", `"serial" header is mandatory`}, + {"operator-id: f22PSauKuNkwQTM9Wz67ZCjNACuSjjhN\n", "", `"operator-id" header is mandatory`}, + {viewsStanza, "views: abcd", `"views" header must be a list`}, + {viewsStanza, "foo: bar", `"views" stanza is mandatory`}, + { + "canonical/network/control-interfaces", + "canonical", + `view at position 3: "name" must be in the format account/registry/view`, + }, + } + + for i, test := range invalidTests { + invalid := strings.Replace(encoded, test.original, test.invalid, 1) + _, err := asserts.Decode([]byte(invalid)) + c.Check(err, ErrorMatches, validationSetErrPrefix+test.expectedErr, Commentf("test %d/%d failed", i+1, len(invalidTests))) + } +} diff --git a/features/features.go b/features/features.go index aa916b5ecfb..798ab12d74c 100644 --- a/features/features.go +++ b/features/features.go @@ -75,6 +75,8 @@ const ( RefreshAppAwarenessUX // Registries enables experimental configuration based on registries and views. Registries + // RegistryControl enables experimental remote management of registries + RegistryControl // AppArmorPrompting enables AppArmor to prompt the user for permission when apps perform certain operations. AppArmorPrompting @@ -119,7 +121,9 @@ var featureNames = map[SnapdFeature]string{ QuotaGroups: "quota-groups", RefreshAppAwarenessUX: "refresh-app-awareness-ux", - Registries: "registries", + + Registries: "registries", + RegistryControl: "registry-control", AppArmorPrompting: "apparmor-prompting", } @@ -146,6 +150,7 @@ var featuresExported = map[SnapdFeature]bool{ RefreshAppAwarenessUX: true, Registries: true, + RegistryControl: true, AppArmorPrompting: true, } diff --git a/features/features_test.go b/features/features_test.go index a9cfebdc0b2..88a6a6f7b26 100644 --- a/features/features_test.go +++ b/features/features_test.go @@ -64,6 +64,7 @@ func (*featureSuite) TestName(c *C) { check(features.QuotaGroups, "quota-groups") check(features.RefreshAppAwarenessUX, "refresh-app-awareness-ux") check(features.Registries, "registries") + check(features.RegistryControl, "registry-control") check(features.AppArmorPrompting, "apparmor-prompting") c.Check(tested, Equals, features.NumberOfFeatures()) @@ -106,6 +107,7 @@ func (*featureSuite) TestIsExported(c *C) { check(features.QuotaGroups, false) check(features.RefreshAppAwarenessUX, true) check(features.Registries, true) + check(features.RegistryControl, true) check(features.AppArmorPrompting, true) c.Check(tested, Equals, features.NumberOfFeatures()) @@ -235,6 +237,7 @@ func (*featureSuite) TestIsEnabledWhenUnset(c *C) { check(features.QuotaGroups, false) check(features.RefreshAppAwarenessUX, false) check(features.Registries, false) + check(features.RegistryControl, false) check(features.AppArmorPrompting, false) c.Check(tested, Equals, features.NumberOfFeatures()) diff --git a/registry/registry_control.go b/registry/registry_control.go new file mode 100644 index 00000000000..06ab13f5735 --- /dev/null +++ b/registry/registry_control.go @@ -0,0 +1,155 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package registry + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +var ( + validAccountID = regexp.MustCompile("^(?:[a-z0-9A-Z]{32}|[-a-z0-9]{2,28})$") +) + +// RegistryControl holds a list of views delegated to an operator. +type RegistryControl struct { + OperatorID string + Registries map[string]*delegatedRegistry // key is / +} + +type delegatedRegistry struct { + AccountID string + Name string + Views map[string]*delegatedView // key is the view name +} + +type delegatedView struct { + Name string +} + +// NewRegistryControl returns a new RegistryControl with the specified views. +func NewRegistryControl(operatorID string, views []interface{}) (*RegistryControl, error) { + if len(views) == 0 { + return nil, errors.New("cannot define registry-control: no views provided") + } + + delegated := make(map[string]*delegatedRegistry) + registryControl := &RegistryControl{ + OperatorID: operatorID, + Registries: delegated, + } + + for i, view := range views { + viewMap, ok := view.(map[string]interface{}) + if !ok || len(viewMap) == 0 { + return nil, fmt.Errorf("view at position %d: must be a non-empty map", i+1) + } + + name, ok := viewMap["name"].(string) + if !ok || len(name) == 0 { + return nil, fmt.Errorf(`view at position %d: "name" must be a string`, i+1) + } + + parts := strings.Split(name, "/") + nParts := len(parts) + + if nParts != 3 { + return nil, fmt.Errorf(`view at position %d: "name" must be in the format account/registry/view`, i+1) + } + + accountID := parts[0] + registryName := parts[1] + viewName := parts[2] + err := registryControl.Delegate(accountID, registryName, viewName) + if err != nil { + return nil, fmt.Errorf("view at position %d: %v", i+1, err) + } + } + + return registryControl, nil +} + +// IsDelegated ... +func (rgCtrl *RegistryControl) IsDelegated(accountID, registryName, view string) bool { + key := fmt.Sprintf("%s/%s", accountID, registryName) + + registry, ok := rgCtrl.Registries[key] + if !ok { + return false + } + + _, ok = registry.Views[view] + return ok +} + +// Delegate ... +func (rgCtrl *RegistryControl) Delegate(accountID, registryName, view string) error { + if !validAccountID.MatchString(accountID) { + return fmt.Errorf("invalid Account ID %s", accountID) + } + + if !ValidRegistryName.MatchString(registryName) { + return fmt.Errorf("invalid registry name %s", registryName) + } + + if !ValidViewName.MatchString(view) { + return fmt.Errorf("invalid view name %s", view) + } + + if rgCtrl.IsDelegated(accountID, registryName, view) { + // already delegated, nothing to do + return nil + } + + key := fmt.Sprintf("%s/%s", accountID, registryName) + registry, ok := rgCtrl.Registries[key] + if !ok { + registry = &delegatedRegistry{ + AccountID: accountID, + Name: registryName, + Views: make(map[string]*delegatedView), + } + } + + registry.Views[view] = &delegatedView{Name: view} + rgCtrl.Registries[key] = registry + + return nil +} + +// Revoke ... +func (rgCtrl *RegistryControl) Revoke(accountID, registryName, view string) error { + if !rgCtrl.IsDelegated(accountID, registryName, view) { + // not delegated, nothing to do + return nil + } + + key := fmt.Sprintf("%s/%s", accountID, registryName) + registry := rgCtrl.Registries[key] + + delete(registry.Views, view) + if len(registry.Views) == 0 { + delete(rgCtrl.Registries, key) + } + + return nil +} diff --git a/registry/registry_control_test.go b/registry/registry_control_test.go new file mode 100644 index 00000000000..17528844d0d --- /dev/null +++ b/registry/registry_control_test.go @@ -0,0 +1,140 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2022-2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package registry_test + +import ( + "github.com/snapcore/snapd/registry" + . "gopkg.in/check.v1" +) + +type rgctrlSuite struct{} + +var _ = Suite(&rgctrlSuite{}) + +func (s *rgctrlSuite) TestNewRegistryControl(c *C) { + type testcase struct { + operatorID string + views []interface{} + err string + } + + tcs := []testcase{ + {err: "cannot define registry-control: no views provided"}, + { + operatorID: "canonical", + views: []interface{}{map[string]interface{}{}}, + err: "view at position 1: must be a non-empty map", + }, + { + operatorID: "canonical", + views: []interface{}{map[string]interface{}{"name": []interface{}{}}}, + err: `view at position 1: "name" must be a string`, + }, + { + operatorID: "canonical", + views: []interface{}{map[string]interface{}{"name": "a/b/c/d"}}, + err: `view at position 1: "name" must be in the format account/registry/view`, + }, + { + operatorID: "canonical", + views: []interface{}{map[string]interface{}{"name": "@foo/network/control-device"}}, + err: "view at position 1: invalid Account ID @foo", + }, + { + operatorID: "canonical", + views: []interface{}{map[string]interface{}{"name": "canonical/123/control-device"}}, + err: "view at position 1: invalid registry name 123", + }, + { + operatorID: "canonical", + views: []interface{}{map[string]interface{}{"name": "canonical/network/_view"}}, + err: "view at position 1: invalid view name _view", + }, + { + operatorID: "canonical", + views: []interface{}{ + map[string]interface{}{"name": "canonical/network/control-interface"}, + map[string]interface{}{"name": "canonical/network/observe-interface"}, + }, + }, + } + + for i, tc := range tcs { + cmt := Commentf("test number %d", i+1) + rgCtrl, err := registry.NewRegistryControl(tc.operatorID, tc.views) + if tc.err != "" { + c.Assert(err, NotNil) + c.Assert(err.Error(), Equals, tc.err, cmt) + } else { + c.Assert(err, IsNil, cmt) + c.Check(rgCtrl, Not(IsNil), cmt) + } + } +} + +func (s *rgctrlSuite) TestDelegate(c *C) { + rgCtrl, err := registry.NewRegistryControl( + "canonical", + []interface{}{ + map[string]interface{}{"name": "canonical/network/control-interface"}, + map[string]interface{}{"name": "canonical/network/observe-interface"}, + }, + ) + c.Assert(err, IsNil) + + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-vpn"), Equals, false) + + err = rgCtrl.Delegate("canonical", "network", "control-vpn") + c.Assert(err, IsNil) + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-vpn"), Equals, true) + + // test idempotency + err = rgCtrl.Delegate("canonical", "network", "control-vpn") + c.Assert(err, IsNil) + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-vpn"), Equals, true) +} + +func (s *rgctrlSuite) TestRevoke(c *C) { + rgCtrl, err := registry.NewRegistryControl( + "canonical", + []interface{}{ + map[string]interface{}{"name": "canonical/network/control-interface"}, + map[string]interface{}{"name": "canonical/network/observe-interface"}, + }, + ) + c.Assert(err, IsNil) + + c.Assert(len(rgCtrl.Registries), Equals, 1) // canonical/network + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-interface"), Equals, true) + + err = rgCtrl.Revoke("canonical", "network", "control-interface") + c.Assert(err, IsNil) + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-interface"), Equals, false) + + // test idempotency + err = rgCtrl.Revoke("canonical", "network", "control-interface") + c.Assert(err, IsNil) + c.Check(rgCtrl.IsDelegated("canonical", "network", "control-interface"), Equals, false) + + // empty delegation + err = rgCtrl.Revoke("canonical", "network", "observe-interface") + c.Assert(err, IsNil) + c.Assert(len(rgCtrl.Registries), Equals, 0) +}