diff --git a/asserts/confdb.go b/asserts/confdb.go index 9a8196723cd..adc957b5c5a 100644 --- a/asserts/confdb.go +++ b/asserts/confdb.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "sort" "time" "github.com/snapcore/snapd/confdb" @@ -119,6 +120,22 @@ type ConfdbControl struct { operators map[string]*confdb.Operator } +// NewConfdbControl returns an empty confdb-control assertion. +func NewConfdbControl(serial *Serial) *ConfdbControl { + return &ConfdbControl{ + assertionBase: assertionBase{ + headers: map[string]interface{}{ + "type": "confdb-control", + "brand-id": serial.BrandID(), + "model": serial.Model(), + "serial": serial.Serial(), + "groups": []interface{}{}, + }, + }, + operators: map[string]*confdb.Operator{}, + } +} + // BrandID returns the brand identifier of the device. func (cc *ConfdbControl) BrandID() string { return cc.HeaderString("brand-id") @@ -135,6 +152,76 @@ func (cc *ConfdbControl) Serial() string { return cc.HeaderString("serial") } +// IsDelegated checks if the view is delegated to the operator with the given auth. +func (cc *ConfdbControl) IsDelegated(operatorID, view string, auth []string) (bool, error) { + operator, ok := cc.operators[operatorID] + if !ok { + return false, nil // nothing is delegated to this operator + } + + return operator.IsDelegated(view, auth) +} + +// Delegate delegates the given views with the provided auth to the operator. +func (cc *ConfdbControl) Delegate(operatorID string, views, auth []string) error { + operator, ok := cc.operators[operatorID] + if !ok { + operator = &confdb.Operator{ID: operatorID} + } + + err := operator.Delegate(views, auth) + if err != nil { + return err + } + + cc.operators[operatorID] = operator + return nil +} + +// Revoke withdraws access to the views that have been delegated with the provided auth. +func (cc *ConfdbControl) Revoke(operatorID string, views, auth []string) error { + operator, ok := cc.operators[operatorID] + if !ok { + return nil // nothing is delegated to this operator + } + + if len(views) == 0 && len(auth) == 0 { + delete(cc.operators, operatorID) // completely revoke access from this operator + return nil + } + + return operator.Revoke(views, auth) +} + +// Groups returns the groups in the raw assertion's format. +func (cc *ConfdbControl) Groups() []interface{} { + ids := make([]string, 0, len(cc.operators)) + for id := range cc.operators { + ids = append(ids, id) + } + sort.Strings(ids) + + var groups []interface{} + for _, id := range ids { // sort by operator + op := cc.operators[id] + for _, group := range op.Groups { + auth, views := []interface{}{}, []interface{}{} + for _, a := range group.Authentication { + auth = append(auth, string(a)) + } + + for _, v := range group.Views { + views = append(views, v.String()) + } + + groups = append(groups, map[string]interface{}{ + "operator-id": op.ID, "authentication": auth, "views": views, + }) + } + } + return groups +} + // assembleConfdbControl creates a new confdb-control assertion after validating // all required fields and constraints. func assembleConfdbControl(assert assertionBase) (Assertion, error) { @@ -209,7 +296,7 @@ func parseConfdbControlGroups(rawGroups []interface{}) (map[string]*confdb.Opera return nil, fmt.Errorf(`%s: "views" must be provided`, errPrefix) } - if err := operator.AddControlGroup(views, auth); err != nil { + if err := operator.Delegate(views, auth); err != nil { return nil, fmt.Errorf(`%s: %w`, errPrefix, err) } } diff --git a/asserts/confdb_test.go b/asserts/confdb_test.go index 80925626ad9..c8e4e3ea92c 100644 --- a/asserts/confdb_test.go +++ b/asserts/confdb_test.go @@ -20,10 +20,12 @@ package asserts_test import ( + "fmt" "strings" "time" . "gopkg.in/check.v1" + "gopkg.in/yaml.v2" "github.com/snapcore/snapd/asserts" "github.com/snapcore/snapd/confdb" @@ -335,12 +337,12 @@ func (s *confdbCtrlSuite) TestDecodeInvalid(c *C) { { " - operator-key", " - foo-bar", - "cannot parse group at position 1: cannot add group: invalid authentication method: foo-bar", + "cannot parse group at position 1: cannot delegate: invalid authentication method: foo-bar", }, { "canonical/network/control-interfaces", "canonical", - `cannot parse group at position 2: view "canonical" must be in the format account/confdb/view`, + `cannot parse group at position 2: cannot delegate: view "canonical" must be in the format account/confdb/view`, }, } @@ -350,3 +352,279 @@ func (s *confdbCtrlSuite) TestDecodeInvalid(c *C) { c.Assert(err, ErrorMatches, validationSetErrPrefix+test.expectedErr, Commentf("test %d/%d failed", i+1, len(invalidTests))) } } + +func (s *confdbCtrlSuite) TestDelegateOK(c *C) { + a, err := asserts.Decode([]byte(confdbControlExample)) + c.Assert(err, IsNil) + + cc := a.(*asserts.ConfdbControl) + delegated, err := cc.IsDelegated("stephen", "canonical/network/control-vpn", []string{"operator-key"}) + c.Check(err, IsNil) + c.Check(delegated, Equals, false) + + cc.Delegate("stephen", []string{"canonical/network/control-vpn"}, []string{"operator-key", "store"}) + cc.Delegate("stephen", []string{"canonical/network/control-interfaces"}, []string{"operator-key"}) + + delegated, _ = cc.IsDelegated("stephen", "canonical/network/control-vpn", []string{"operator-key"}) + c.Check(delegated, Equals, true) + + delegated, _ = cc.IsDelegated("stephen", "canonical/network/control-interfaces", []string{"store"}) + c.Check(delegated, Equals, false) +} + +func (s *confdbCtrlSuite) TestDelegateInvalid(c *C) { + a, err := asserts.Decode([]byte(confdbControlExample)) + c.Assert(err, IsNil) + + cc := a.(*asserts.ConfdbControl) + err = cc.Delegate("john", []string{"c#anonical/network/control-vpn"}, []string{"store"}) + c.Check(err, ErrorMatches, "cannot delegate: invalid Account ID c#anonical") + + delegated, err := cc.IsDelegated("john", "c#anonical/network/control-vpn", []string{"operator-key"}) + c.Check(err, ErrorMatches, "invalid Account ID c#anonical") + c.Check(delegated, Equals, false) +} + +func (s *confdbCtrlSuite) TestRevokeOK(c *C) { + a, err := asserts.Decode([]byte(confdbControlExample)) + c.Assert(err, IsNil) + + cc := a.(*asserts.ConfdbControl) + delegated, _ := cc.IsDelegated("john", "canonical/network/control-interfaces", []string{"store"}) + c.Check(delegated, Equals, true) + + cc.Revoke("john", []string{"canonical/network/control-interfaces"}, []string{"store"}) + delegated, _ = cc.IsDelegated("john", "canonical/network/control-interfaces", []string{"store"}) + c.Check(delegated, Equals, false) + + cc.Revoke("jane", nil, nil) + delegated, _ = cc.IsDelegated("jane", "canonical/network/observe-interfaces", []string{"store", "operator-key"}) + c.Check(delegated, Equals, false) + + err = cc.Revoke("who?", []string{"canonical/network/control-device"}, []string{"operator-key"}) + c.Assert(err, IsNil) +} + +func (s *confdbCtrlSuite) TestRevokeInvalid(c *C) { + encoded := confdbControlExample + a, err := asserts.Decode([]byte(encoded)) + c.Assert(err, IsNil) + + cc := a.(*asserts.ConfdbControl) + err = cc.Revoke("jane", []string{"c#anonical/network/observe-interfaces"}, []string{"store"}) + c.Check(err, ErrorMatches, "cannot revoke: invalid Account ID c#anonical") +} + +func (s *confdbCtrlSuite) TestHeurisrics(c *C) { + type testcase struct { + before string + action string + operatorID string + authentication []string + views []string + after string + } + + tcs := []testcase{ + { + before: `groups: + - + authentication: + - operator-key + operator-id: john + views: + - aa/b/c + - dd/e/f`, + action: "delegate", + operatorID: "john", + authentication: []string{"store", "operator-key"}, + views: []string{"xx/y/z", "ii/j/k", "aa/b/c", "uu/v/w"}, + after: `groups: +- authentication: + - operator-key + operator-id: john + views: + - dd/e/f +- authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c + - ii/j/k + - uu/v/w + - xx/y/z +`, + }, + { + before: `groups: + - + authentication: + - operator-key + operator-id: john + views: + - aa/b/c + - dd/e/f`, + action: "delegate", + operatorID: "jane", + authentication: []string{"store"}, + views: []string{"aa/b/c"}, + after: `groups: +- authentication: + - store + operator-id: jane + views: + - aa/b/c +- authentication: + - operator-key + operator-id: john + views: + - aa/b/c + - dd/e/f +`, + }, + { + before: `groups: + - + authentication: + - operator-key + operator-id: john + views: + - dd/e/f + - + authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c + - xx/y/z`, + action: "revoke", + operatorID: "john", + views: []string{"xx/y/z", "dd/e/f"}, + after: `groups: +- authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c +`, + }, + { + before: `groups: + - + authentication: + - store + operator-id: jane + views: + - aa/b/c + - + authentication: + - operator-key + operator-id: john + views: + - dd/e/f + - + authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c + - xx/y/z`, + action: "revoke", + operatorID: "john", + after: `groups: +- authentication: + - store + operator-id: jane + views: + - aa/b/c +`, + }, + { + before: `groups: + - + authentication: + - operator-key + operator-id: john + views: + - dd/e/f + - + authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c + - xx/y/z`, + action: "revoke", + operatorID: "john", + authentication: []string{"store"}, + views: []string{"xx/y/z"}, + after: `groups: +- authentication: + - operator-key + operator-id: john + views: + - dd/e/f + - xx/y/z +- authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c +`, + }, + { + before: `groups: + - + authentication: + - operator-key + operator-id: john + views: + - dd/e/f + - + authentication: + - operator-key + - store + operator-id: john + views: + - aa/b/c`, + action: "revoke", + operatorID: "john", + views: []string{"aa/b/c", "dd/e/f"}, + after: `groups: [] +`, + }, + } + + prefix := `type: confdb-control +brand-id: generic +model: generic-classic +serial: 03961d5d-26e5-443f-838d-6db046126bea` + suffix := ` +sign-key-sha3-384: t9yuKGLyiezBq_PXMJZsGdkTukmL7MgrgqXAlxxiZF4TYryOjZcy48nnjDmEHQDp + +AXNpZw==` + for i, tc := range tcs { + cmt := Commentf("test number %d", i+1) + assertion := fmt.Sprintf("%s\n%s%s", prefix, tc.before, suffix) + a, err := asserts.Decode([]byte(assertion)) + c.Assert(err, IsNil, cmt) + + cc := a.(*asserts.ConfdbControl) + if tc.action == "delegate" { + err = cc.Delegate(tc.operatorID, tc.views, tc.authentication) + } else { + err = cc.Revoke(tc.operatorID, tc.views, tc.authentication) + } + c.Assert(err, IsNil) + + out, err := yaml.Marshal(map[string]interface{}{"groups": cc.Groups()}) + c.Check(err, IsNil, cmt) + c.Check(string(out), Equals, tc.after, cmt) + } +} diff --git a/confdb/confdb_control.go b/confdb/confdb_control.go index 5246777c638..3de6af93f25 100644 --- a/confdb/confdb_control.go +++ b/confdb/confdb_control.go @@ -91,60 +91,267 @@ type ViewRef struct { View string } +// newViewRef parses account/confdb/view into ViewRef +func newViewRef(view string) (*ViewRef, error) { + viewPath := strings.Split(view, "/") + if len(viewPath) != 3 { + return nil, fmt.Errorf(`view "%s" must be in the format account/confdb/view`, view) + } + + account := viewPath[0] + if !validAccountID.MatchString(account) { + return nil, fmt.Errorf("invalid Account ID %s", account) + } + + confdb := viewPath[1] + if !ValidConfdbName.MatchString(confdb) { + return nil, fmt.Errorf("invalid confdb name %s", confdb) + } + + viewName := viewPath[2] + if !ValidViewName.MatchString(viewName) { + return nil, fmt.Errorf("invalid view name %s", viewName) + } + + return &ViewRef{Account: account, Confdb: confdb, View: viewName}, nil +} + +// String returns the string representation of the ViewRef. +func (v *ViewRef) String() string { + return fmt.Sprintf("%s/%s/%s", v.Account, v.Confdb, v.View) +} + +// compare compares two ViewRefs lexicographically based the Account, Confdb, & View field. +func (v *ViewRef) compare(b *ViewRef) int { + if v.Account != b.Account { + if v.Account < b.Account { + return -1 + } + return 1 + } + + if v.Confdb != b.Confdb { + if v.Confdb < b.Confdb { + return -1 + } + return 1 + } + + if v.View != b.View { + if v.View < b.View { + return -1 + } + return 1 + } + + return 0 +} + +// groupWithView returns the group that holds the given view. +func (op *Operator) groupWithView(view *ViewRef) (*ControlGroup, int) { + for _, group := range op.Groups { + n := len(group.Views) + idx := sort.Search(n, func(i int) bool { + return group.Views[i].compare(view) >= 0 + }) + + if idx < n && group.Views[idx].compare(view) == 0 { + return group, idx + } + } + + return nil, 0 +} + +// groupWithAuthentication returns the group with the given auth (sorted). +func (op *Operator) groupWithAuthentication(auth []AuthenticationMethod) *ControlGroup { + for _, group := range op.Groups { + if len(group.Authentication) == len(auth) { + equal := true + for i := range auth { + if auth[i] != group.Authentication[i] { + equal = false + break + } + } + if equal { + return group + } + } + } + + return nil +} + +// IsDelegated checks if the view is delegated to the operator with the given auth. +func (op *Operator) IsDelegated(view string, rawAuth []string) (bool, error) { + parsedView, err := newViewRef(view) + if err != nil { + return false, err + } + + auth, err := convertToAuthenticationMethods(rawAuth) + if err != nil { + return false, err + } + + group, _ := op.groupWithView(parsedView) + if group == nil { + return false, nil + } + + i, j := 0, 0 + for i < len(auth) && j < len(group.Authentication) { + if auth[i] == group.Authentication[j] { + i++ + j++ + } else if auth[i] > group.Authentication[j] { + j++ + } else { + return false, nil + } + } + + return i == len(auth), nil +} + // AddControlGroup adds the group to an operator under the given authentication. -func (op *Operator) AddControlGroup(views, auth []string) error { - if len(auth) == 0 { - return errors.New(`cannot add group: "auth" must be a non-empty list`) +func (op *Operator) Delegate(views, rawAuth []string) error { + if len(rawAuth) == 0 { + return errors.New(`cannot delegate: "auth" must be a non-empty list`) } - authentication, err := convertToAuthenticationMethods(auth) + auth, err := convertToAuthenticationMethods(rawAuth) if err != nil { - return fmt.Errorf("cannot add group: %w", err) + return fmt.Errorf("cannot delegate: %w", err) } if len(views) == 0 { - return errors.New(`cannot add group: "views" must be a non-empty list`) + return errors.New(`cannot delegate: "views" must be a non-empty list`) } - parsedViews := []*ViewRef{} for _, view := range views { - viewPath := strings.Split(view, "/") - if len(viewPath) != 3 { - return fmt.Errorf(`view "%s" must be in the format account/confdb/view`, view) + parsedView, err := newViewRef(view) + if err != nil { + return fmt.Errorf("cannot delegate: %w", err) } + op.delegateOne(parsedView, auth) + } - account := viewPath[0] - if !validAccountID.MatchString(account) { - return fmt.Errorf("invalid Account ID %s", account) - } + op.compact() + return nil +} - confdb := viewPath[1] - if !ValidConfdbName.MatchString(confdb) { - return fmt.Errorf("invalid confdb name %s", confdb) - } +// delegateOne grants remote control to the view. +func (op *Operator) delegateOne(view *ViewRef, auth []AuthenticationMethod) { + newAuth := auth + existingGroup, idx := op.groupWithView(view) + if existingGroup != nil { + newAuth = append(newAuth, existingGroup.Authentication...) + sort.Slice(newAuth, func(i, j int) bool { + return newAuth[i] < newAuth[j] + }) + newAuth = unique(newAuth) + } + + newGroup := op.groupWithAuthentication(newAuth) + if existingGroup == newGroup && existingGroup != nil { + return // already delegated, nothing to do + } + + if newGroup == nil { + newGroup = &ControlGroup{Authentication: newAuth, Views: []*ViewRef{view}} + op.Groups = append(op.Groups, newGroup) + } else { + newGroup.Views = append(newGroup.Views, view) + sort.Slice(newGroup.Views, func(i, j int) bool { + return newGroup.Views[i].compare(newGroup.Views[j]) < 0 + }) + } + + if existingGroup != nil { + // remove the view from the group + existingGroup.Views = append(existingGroup.Views[:idx], existingGroup.Views[idx+1:]...) + } +} - viewName := viewPath[2] - if !ValidViewName.MatchString(viewName) { - return fmt.Errorf("invalid view name %s", viewName) +// Revoke withdraws remote access to the views that have been delegated with the given auth. +func (op *Operator) Revoke(views []string, rawAuth []string) error { + var err error + var auth []AuthenticationMethod + if len(rawAuth) == 0 { + auth = []AuthenticationMethod{OperatorKey, Store} // revoke all auth methods + } else { + auth, err = convertToAuthenticationMethods(rawAuth) + if err != nil { + return fmt.Errorf("cannot revoke: %w", err) } + } - parsedView := &ViewRef{ - Account: account, - Confdb: confdb, - View: viewName, + var parsedViews []*ViewRef + if len(views) == 0 { + for _, group := range op.Groups { + parsedViews = append(parsedViews, group.Views...) + } + } else { + for _, view := range views { + parsedView, err := newViewRef(view) + if err != nil { + return fmt.Errorf("cannot revoke: %w", err) + } + parsedViews = append(parsedViews, parsedView) } - parsedViews = append(parsedViews, parsedView) } - group := &ControlGroup{ - Authentication: authentication, - Views: parsedViews, + for _, view := range parsedViews { + op.revokeOne(view, auth) } - op.Groups = append(op.Groups, group) + op.compact() return nil } +// revokeOne revokes remote control over the view. +func (op *Operator) revokeOne(view *ViewRef, auth []AuthenticationMethod) { + group, idx := op.groupWithView(view) + if group == nil { + return // not delegated, nothing to do + } + + remaining := make([]AuthenticationMethod, 0, len(group.Authentication)) + for _, existingAuth := range group.Authentication { + found := false + for _, a := range auth { + if a == existingAuth { + found = true + break + } + } + if !found { + remaining = append(remaining, existingAuth) + } + } + + // remove the view from the group + group.Views = append(group.Views[:idx], group.Views[idx+1:]...) + + if len(remaining) > 0 { + op.delegateOne(view, remaining) // delegate with remaining auth + } +} + +// compact removes empty groups. +func (op *Operator) compact() { + groups := make([]*ControlGroup, 0, len(op.Groups)) + for _, group := range op.Groups { + if len(group.Views) != 0 { + groups = append(groups, group) + } + } + + op.Groups = groups +} + // unique replaces consecutive runs of equal elements with a single copy. // The provided slice s should be sorted. func unique[T comparable](s []T) []T { diff --git a/confdb/confdb_control_test.go b/confdb/confdb_control_test.go index a13264541f0..b8ba5b301ec 100644 --- a/confdb/confdb_control_test.go +++ b/confdb/confdb_control_test.go @@ -48,26 +48,105 @@ func (s *confdbCtrlSuite) TestConvertToAuthenticationMethods(c *C) { c.Assert(converted, DeepEquals, expected) } -func (s *confdbCtrlSuite) TestAddGroupOK(c *C) { +func (s *confdbCtrlSuite) TestCompareViewRef(c *C) { + observeDevice := confdb.ViewRef{Account: "canonical", Confdb: "device", View: "observe-device"} + controlDevice := confdb.ViewRef{Account: "canonical", Confdb: "device", View: "control-device"} + observeInterface := confdb.ViewRef{Account: "canonical", Confdb: "network", View: "observe-interface"} + controlConfig := confdb.ViewRef{Account: "system", Confdb: "telemetry", View: "control-config"} + + type testcase struct { + a confdb.ViewRef + b confdb.ViewRef + result int + } + tcs := []testcase{ + {a: observeDevice, b: observeDevice, result: 0}, + {a: observeDevice, b: controlDevice, result: 1}, + {a: controlDevice, b: observeDevice, result: -1}, + {a: observeInterface, b: controlDevice, result: 1}, + {a: controlDevice, b: observeInterface, result: -1}, + {a: controlConfig, b: controlDevice, result: 1}, + {a: controlDevice, b: controlConfig, result: -1}, + } + for i, tc := range tcs { + result := tc.a.Compare(&tc.b) + c.Assert(result, Equals, tc.result, Commentf("test number %d", i+1)) + } +} + +func (s *confdbCtrlSuite) TestGroupWithView(c *C) { operator := confdb.Operator{ID: "canonical"} + err := operator.Delegate( + []string{"canonical/network/control-interface", "canonical/network/observe-interface"}, + []string{"operator-key"}, + ) + c.Assert(err, IsNil) - views := []string{"canonical/network/control-device", "canonical/network/observe-device"} - auth := []string{"operator-key", "store"} - err := operator.AddControlGroup(views, auth) + err = operator.Delegate( + []string{"canonical/network/control-device", "canonical/network/observe-device"}, + []string{"store"}, + ) + c.Assert(err, IsNil) + + group, idx := operator.GroupWithView(&confdb.ViewRef{ + Account: "canonical", Confdb: "network", View: "control-interface", + }) + c.Assert(group, Equals, operator.Groups[0]) + c.Assert(idx, Equals, 0) + + group, idx = operator.GroupWithView(&confdb.ViewRef{ + Account: "canonical", Confdb: "network", View: "observe-device", + }) + c.Assert(group, Equals, operator.Groups[1]) + c.Assert(idx, Equals, 1) +} + +func (s *confdbCtrlSuite) TestGroupWithAuthentication(c *C) { + operator := confdb.Operator{ID: "canonical"} + err := operator.Delegate([]string{"canonical/network/control-interface"}, []string{"operator-key"}) + c.Assert(err, IsNil) + + err = operator.Delegate([]string{"canonical/network/observe-device"}, []string{"store"}) + c.Assert(err, IsNil) + + err = operator.Delegate( + []string{"canonical/network/observe-interface"}, []string{"store", "operator-key"}, + ) + c.Assert(err, IsNil) + + group := operator.GroupWithAuthentication([]confdb.AuthenticationMethod{confdb.OperatorKey}) + c.Assert(group, Equals, operator.Groups[0]) + + group = operator.GroupWithAuthentication([]confdb.AuthenticationMethod{confdb.Store}) + c.Assert(group, Equals, operator.Groups[1]) + + group = operator.GroupWithAuthentication([]confdb.AuthenticationMethod{confdb.OperatorKey, confdb.Store}) + c.Assert(group, Equals, operator.Groups[2]) +} + +func (s *confdbCtrlSuite) TestDelegateOK(c *C) { + operator := confdb.Operator{ID: "canonical"} + err := operator.Delegate( + []string{"canonical/network/control-device", "canonical/network/observe-device"}, + []string{"operator-key", "store"}, + ) c.Assert(err, IsNil) - c.Assert(len(operator.Groups), Equals, 1) - g := operator.Groups[0] expectedViews := []*confdb.ViewRef{ {Account: "canonical", Confdb: "network", View: "control-device"}, {Account: "canonical", Confdb: "network", View: "observe-device"}, } - c.Assert(g.Views, DeepEquals, expectedViews) + c.Assert(operator.Groups[0].Views, DeepEquals, expectedViews) expectedAuth := []confdb.AuthenticationMethod{confdb.OperatorKey, confdb.Store} - c.Assert(g.Authentication, DeepEquals, expectedAuth) + c.Assert(operator.Groups[0].Authentication, DeepEquals, expectedAuth) + + // test idempotency + err = operator.Delegate([]string{"canonical/network/control-device"}, []string{"operator-key", "store"}) + c.Assert(err, IsNil) + c.Assert(len(operator.Groups), Equals, 1) } -func (s *confdbCtrlSuite) TestAddGroupFail(c *C) { +func (s *confdbCtrlSuite) TestDelegateFail(c *C) { operator := confdb.Operator{ID: "canonical"} type testcase struct { @@ -76,45 +155,107 @@ func (s *confdbCtrlSuite) TestAddGroupFail(c *C) { err string } tcs := []testcase{ - {err: `cannot add group: "auth" must be a non-empty list`}, - {auth: []string{"magic"}, err: "cannot add group: invalid authentication method: magic"}, - {auth: []string{"store"}, err: `cannot add group: "views" must be a non-empty list`}, + {err: `cannot delegate: "auth" must be a non-empty list`}, + {auth: []string{"magic"}, err: "cannot delegate: invalid authentication method: magic"}, + {auth: []string{"store"}, err: `cannot delegate: "views" must be a non-empty list`}, { views: []string{"a/b/c/d"}, auth: []string{"store"}, - err: `view "a/b/c/d" must be in the format account/confdb/view`, + err: `cannot delegate: view "a/b/c/d" must be in the format account/confdb/view`, }, { views: []string{"a/b"}, auth: []string{"store"}, - err: `view "a/b" must be in the format account/confdb/view`, + err: `cannot delegate: view "a/b" must be in the format account/confdb/view`, }, { views: []string{"ab/"}, auth: []string{"store"}, - err: `view "ab/" must be in the format account/confdb/view`, + err: `cannot delegate: view "ab/" must be in the format account/confdb/view`, }, { views: []string{"@foo/network/control-device"}, auth: []string{"store"}, - err: "invalid Account ID @foo", + err: "cannot delegate: invalid Account ID @foo", }, { views: []string{"canonical/123/control-device"}, auth: []string{"store"}, - err: "invalid confdb name 123", + err: "cannot delegate: invalid confdb name 123", }, { views: []string{"canonical/network/_view"}, auth: []string{"store"}, - err: "invalid view name _view", + err: "cannot delegate: invalid view name _view", + }, + } + + for i, tc := range tcs { + cmt := Commentf("test number %d", i+1) + err := operator.Delegate(tc.views, tc.auth) + c.Assert(err, NotNil, cmt) + c.Assert(err, ErrorMatches, tc.err, cmt) + } +} + +func (s *confdbCtrlSuite) TestRevoke(c *C) { + operator := confdb.Operator{ID: "john"} + err := operator.Delegate( + []string{"canonical/network/control-interface", "canonical/network/observe-interface"}, + []string{"operator-key"}, + ) + c.Assert(err, IsNil) + + operator.Revoke([]string{"canonical/network/control-interface"}, []string{"operator-key"}) + delegated, err := operator.IsDelegated("canonical/network/control-interface", []string{"operator-key"}) + c.Check(err, IsNil) + c.Check(delegated, Equals, false) + + // test idempotency + operator.Revoke([]string{"canonical/network/control-interface"}, []string{"operator-key"}) + delegated, _ = operator.IsDelegated("canonical/network/control-interface", []string{"operator-key"}) + c.Check(delegated, Equals, false) + + // revoke all auth + err = operator.Delegate( + []string{"canonical/network/observe-interface", "canonical/network/control-vpn"}, + []string{"store", "operator-key"}, + ) + c.Assert(err, IsNil) + + err = operator.Revoke([]string{"canonical/network/observe-interface"}, nil) + c.Assert(err, IsNil) + + delegated, err = operator.IsDelegated("canonical/network/observe-interface", []string{"operator-key"}) + c.Check(err, IsNil) + c.Check(delegated, Equals, false) + + delegated, err = operator.IsDelegated("canonical/network/observe-interface", []string{"store"}) + c.Check(err, IsNil) + c.Check(delegated, Equals, false) +} + +func (s *confdbCtrlSuite) TestRevokeFail(c *C) { + operator := confdb.Operator{ID: "canonical"} + + type testcase struct { + views []string + auth []string + err string + } + tcs := []testcase{ + {auth: []string{"magic"}, err: "cannot revoke: invalid authentication method: magic"}, + { + views: []string{"invalid"}, + auth: []string{"store"}, + err: `cannot revoke: view "invalid" must be in the format account/confdb/view`, }, } for i, tc := range tcs { cmt := Commentf("test number %d", i+1) - err := operator.AddControlGroup(tc.views, tc.auth) - c.Assert(err, NotNil) + err := operator.Revoke(tc.views, tc.auth) + c.Assert(err, NotNil, cmt) c.Assert(err, ErrorMatches, tc.err, cmt) } } diff --git a/confdb/export_test.go b/confdb/export_test.go index 828159840ab..1dea28fec60 100644 --- a/confdb/export_test.go +++ b/confdb/export_test.go @@ -34,3 +34,18 @@ var IsValidAuthenticationMethod = isValidAuthenticationMethod // convertToAuthenticationMethods exposed for tests var ConvertToAuthenticationMethods = convertToAuthenticationMethods + +// groupWithView exposed for tests +func (o *Operator) GroupWithView(view *ViewRef) (*ControlGroup, int) { + return o.groupWithView(view) +} + +// groupWithAuthentication exposed for tests +func (o *Operator) GroupWithAuthentication(auth []AuthenticationMethod) *ControlGroup { + return o.groupWithAuthentication(auth) +} + +// compare exposed for tests +func (v *ViewRef) Compare(b *ViewRef) int { + return v.compare(b) +} diff --git a/daemon/api.go b/daemon/api.go index 0bab9e5884b..75df9d857d0 100644 --- a/daemon/api.go +++ b/daemon/api.go @@ -83,6 +83,7 @@ var api = []*Command{ quotaGroupsCmd, quotaGroupInfoCmd, confdbCmd, + confdbControlCmd, noticesCmd, noticeCmd, requestsPromptsCmd, diff --git a/daemon/api_confdb.go b/daemon/api_confdb.go index 1c4551b3671..49549cb44d6 100644 --- a/daemon/api_confdb.go +++ b/daemon/api_confdb.go @@ -24,10 +24,13 @@ import ( "fmt" "net/http" + "github.com/snapcore/snapd/asserts" "github.com/snapcore/snapd/confdb" "github.com/snapcore/snapd/features" + "github.com/snapcore/snapd/overlord/assertstate" "github.com/snapcore/snapd/overlord/auth" "github.com/snapcore/snapd/overlord/configstate/config" + "github.com/snapcore/snapd/overlord/devicestate" "github.com/snapcore/snapd/overlord/state" "github.com/snapcore/snapd/strutil" ) @@ -40,6 +43,11 @@ var ( ReadAccess: authenticatedAccess{Polkit: polkitActionManage}, WriteAccess: authenticatedAccess{Polkit: polkitActionManage}, } + confdbControlCmd = &Command{ + Path: "/v2/confdbs", + POST: handleConfdbControlAction, + WriteAccess: authenticatedAccess{Polkit: polkitActionManage}, + } ) func getView(c *Command, r *http.Request, _ *auth.UserState) Response { @@ -47,7 +55,7 @@ func getView(c *Command, r *http.Request, _ *auth.UserState) Response { st.Lock() defer st.Unlock() - if err := validateConfdbFeatureFlag(st); err != nil { + if err := validateFeatureFlag(st, features.Confdbs); err != nil { return err } @@ -73,7 +81,7 @@ func setView(c *Command, r *http.Request, _ *auth.UserState) Response { st.Lock() defer st.Unlock() - if err := validateConfdbFeatureFlag(st); err != nil { + if err := validateFeatureFlag(st, features.Confdbs); err != nil { return err } @@ -122,16 +130,98 @@ func toAPIError(err error) *apiError { } } -func validateConfdbFeatureFlag(st *state.State) *apiError { +func validateFeatureFlag(st *state.State, feature features.SnapdFeature) *apiError { tr := config.NewTransaction(st) - enabled, err := features.Flag(tr, features.Confdbs) + enabled, err := features.Flag(tr, feature) if err != nil && !config.IsNoOption(err) { - return InternalError(fmt.Sprintf("internal error: cannot check confdbs feature flag: %s", err)) + return InternalError( + fmt.Sprintf("internal error: cannot check %s feature flag: %s", feature.String(), err), + ) } if !enabled { - _, confName := features.Confdbs.ConfigOption() - return BadRequest(fmt.Sprintf(`"confdbs" feature flag is disabled: set '%s' to true`, confName)) + _, confName := feature.ConfigOption() + return BadRequest( + fmt.Sprintf(`"%s" feature flag is disabled: set '%s' to true`, feature.String(), confName), + ) } return nil } + +type confdbControlAction struct { + Action string `json:"action"` + OperatorID string `json:"operator-id"` + Views []string `json:"views"` + Authentication []string `json:"authentication"` +} + +func handleConfdbControlAction(c *Command, r *http.Request, user *auth.UserState) Response { + st := c.d.state + st.Lock() + defer st.Unlock() + + if err := validateFeatureFlag(st, features.Confdbs); err != nil { + return err + } + + if err := validateFeatureFlag(st, features.ConfdbControl); err != nil { + return err + } + + devMgr := c.d.overlord.DeviceManager() + cc, err := getOrCreateConfdbControl(st, devMgr) + if err != nil { + return InternalError(err.Error()) + } + + var a confdbControlAction + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&a); err != nil { + return BadRequest("cannot decode request body into action: %v", err) + } + + switch a.Action { + case "delegate": + err = cc.Delegate(a.OperatorID, a.Views, a.Authentication) + case "revoke": + err = cc.Revoke(a.OperatorID, a.Views, a.Authentication) + default: + return BadRequest("unknown action %q", a.Action) + } + if err != nil { + return InternalError(err.Error()) + } + + // cc, err = devMgr.SignConfdbControl(cc.Groups(), cc.Revision()+1) + // if err != nil { + // return InternalError(err.Error()) + // } + + // if err := assertstate.Add(st, cc); err != nil { + // return InternalError(err.Error()) + // } + + return AssertResponse([]asserts.Assertion{cc}, false) +} + +func getOrCreateConfdbControl(st *state.State, devMgr *devicestate.DeviceManager) (*asserts.ConfdbControl, error) { + serial, err := devMgr.Serial() + if err != nil { + return nil, err + } + + db := assertstate.DB(st) + as, err := db.Find(asserts.ConfdbControlType, map[string]string{ + "brand-id": serial.BrandID(), + "model": serial.Model(), + "serial": serial.Serial(), + }) + + if errors.Is(err, &asserts.NotFoundError{}) { + return asserts.NewConfdbControl(serial), nil + } + if err != nil { + return nil, err + } + return as.(*asserts.ConfdbControl), nil +}