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)
+}