diff --git a/asserts/confdb.go b/asserts/confdb.go index 9a8196723cd..f4fe2e04404 100644 --- a/asserts/confdb.go +++ b/asserts/confdb.go @@ -209,7 +209,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/confdb/confdb_control.go b/confdb/confdb_control.go index 5246777c638..b3072e4a7b0 100644 --- a/confdb/confdb_control.go +++ b/confdb/confdb_control.go @@ -83,6 +83,29 @@ type ControlGroup struct { Views []*ViewRef } +func (g *ControlGroup) findView(target *ViewRef) (int, bool) { + left, right := 0, len(g.Views)-1 + + for left <= right { + mid := (left + right) / 2 + cmp := g.Views[mid].Compare(target) + + if cmp == 0 { + return mid, true + } else if cmp < 0 { + left = mid + 1 + } else { + right = mid - 1 + } + } + + return left, false +} + +func (g *ControlGroup) deleteViewAt(index int) { + g.Views = append(g.Views[:index], g.Views[index+1:]...) +} + // ViewRef holds the reference to account/confdb/view as parsed from the // confdb-control assertion. type ViewRef struct { @@ -91,13 +114,105 @@ type ViewRef struct { View string } +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 +} + +func (v *ViewRef) Compare(b *ViewRef) int { + // First, compare Account + if v.Account != b.Account { + if v.Account < b.Account { + return -1 + } + return 1 + } + + // If Accounts are equal, compare Confdb + if v.Confdb != b.Confdb { + if v.Confdb < b.Confdb { + return -1 + } + return 1 + } + + // If Confdb are equal, compare View + if v.View != b.View { + if v.View < b.View { + return -1 + } + return 1 + } + + // All fields are equal + return 0 +} + +// groupWithView returns the group that holds the given view. +func (op *Operator) groupWithView(view *ViewRef) (*ControlGroup, int) { + for _, group := range op.Groups { + index, ok := group.findView(view) + if ok { + return group, index + } + } + + return nil, 0 +} + +// groupWithAuthentication returns the group with the given authentication. +// The authentication should be sorted. +func (operator *Operator) groupWithAuthentication(auth []AuthenticationMethod) *ControlGroup { + for _, group := range operator.Groups { + if checkListEqual(group.Authentication, auth) { + return group + } + } + + return nil +} + +// IsDelegated checks if // is delegated to +// the operator under the authentication method . +func (operator *Operator) IsDelegated(view *ViewRef, auth AuthenticationMethod) bool { + group, _ := operator.groupWithView(view) + if group == nil { + return false + } + + return checkListContains(group.Authentication, auth) +} + // AddControlGroup adds the group to an operator under the given authentication. -func (op *Operator) AddControlGroup(views, auth []string) error { - if len(auth) == 0 { +func (op *Operator) Delegate(views, rawAuth []string) error { + if len(rawAuth) == 0 { return errors.New(`cannot add group: "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) } @@ -106,45 +221,120 @@ func (op *Operator) AddControlGroup(views, auth []string) error { return errors.New(`cannot add group: "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 err } - account := viewPath[0] - if !validAccountID.MatchString(account) { - return fmt.Errorf("invalid Account ID %s", account) + err = op.delegateOne(parsedView, auth) + if err != nil { + return err } + } + + op.compact() + return nil +} + +// delegateOne grants remote registry control to //. +func (op *Operator) delegateOne(view *ViewRef, auth []AuthenticationMethod) error { + newAuth := auth + existingGroup, viewIdx := 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 { + // already delegated, nothing to do + return nil + } - confdb := viewPath[1] - if !ValidConfdbName.MatchString(confdb) { - return fmt.Errorf("invalid confdb name %s", confdb) + 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 old group + existingGroup.deleteViewAt(viewIdx) + } + + return nil +} + +// Revoke withdraws remote access to the views that have been delegated under +// the authentication methods. +func (op *Operator) Revoke(views []string, rawAuth []string) error { + auth, err := convertToAuthenticationMethods(rawAuth) + if err != nil { + return fmt.Errorf("cannot revoke ...: %w", err) + } + + for _, view := range views { + parsedView, err := newViewRef(view) + if err != nil { + return err } - viewName := viewPath[2] - if !ValidViewName.MatchString(viewName) { - return fmt.Errorf("invalid view name %s", viewName) + err = op.revokeOne(parsedView, auth) + if err != nil { + return err } + } - parsedView := &ViewRef{ - Account: account, - Confdb: confdb, - View: viewName, + op.compact() + return nil +} + +// revokeOne revokes remote registry control over //. +func (op *Operator) revokeOne(view *ViewRef, auth []AuthenticationMethod) error { + group, viewIdx := op.groupWithView(view) + if group == nil { + // not delegated, nothing to do + return nil + } + + remaining := make([]AuthenticationMethod, 0, len(group.Authentication)) + for _, existingAuth := range group.Authentication { + if !checkListContains(auth, existingAuth) { + remaining = append(remaining, existingAuth) } - parsedViews = append(parsedViews, parsedView) } - group := &ControlGroup{ - Authentication: authentication, - Views: parsedViews, + // remove the view from the group + group.deleteViewAt(viewIdx) + + if len(remaining) != 0 { + // delegate the view with the remaining authentication method(s) + return op.delegateOne(view, remaining) } - op.Groups = append(op.Groups, group) return nil } +// 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 { @@ -162,3 +352,29 @@ func unique[T comparable](s []T) []T { return s[:j] } + +// checkListContains checks if the slice contains the given value. +func checkListContains[T comparable](slice []T, value T) bool { + for _, item := range slice { + if item == value { + return true + } + } + + return false +} + +// checkListEqual checks if two slices are equal. +func checkListEqual[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/confdb/confdb_control_test.go b/confdb/confdb_control_test.go index a13264541f0..26b7c4b05c5 100644 --- a/confdb/confdb_control_test.go +++ b/confdb/confdb_control_test.go @@ -53,7 +53,7 @@ func (s *confdbCtrlSuite) TestAddGroupOK(c *C) { views := []string{"canonical/network/control-device", "canonical/network/observe-device"} auth := []string{"operator-key", "store"} - err := operator.AddControlGroup(views, auth) + err := operator.Delegate(views, auth) c.Assert(err, IsNil) c.Assert(len(operator.Groups), Equals, 1) @@ -113,7 +113,7 @@ func (s *confdbCtrlSuite) TestAddGroupFail(c *C) { for i, tc := range tcs { cmt := Commentf("test number %d", i+1) - err := operator.AddControlGroup(tc.views, tc.auth) + err := operator.Delegate(tc.views, tc.auth) c.Assert(err, NotNil) c.Assert(err, ErrorMatches, tc.err, cmt) } diff --git a/daemon/api_confdb.go b/daemon/api_confdb.go index 1c4551b3671..3d3b96991c7 100644 --- a/daemon/api_confdb.go +++ b/daemon/api_confdb.go @@ -135,3 +135,18 @@ func validateConfdbFeatureFlag(st *state.State) *apiError { } return nil } + +func validateConfdbControlFeatureFlag(st *state.State) *apiError { + tr := config.NewTransaction(st) + enabled, err := features.Flag(tr, features.ConfdbControl) + + if err != nil && !config.IsNoOption(err) { + return InternalError(fmt.Sprintf("internal error: cannot check confdb-control feature flag: %s", err)) + } + + if !enabled { + _, confName := features.ConfdbControl.ConfigOption() + return BadRequest(fmt.Sprintf(`"confdb-control" feature flag is disabled: set '%s' to true`, confName)) + } + return nil +}