Skip to content

Commit

Permalink
confdb, daemon: add confdb-control api
Browse files Browse the repository at this point in the history
  • Loading branch information
st3v3nmw committed Dec 17, 2024
1 parent 6eed2b2 commit 1e10d53
Show file tree
Hide file tree
Showing 4 changed files with 259 additions and 28 deletions.
2 changes: 1 addition & 1 deletion asserts/confdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
266 changes: 241 additions & 25 deletions confdb/confdb_control.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 <accountID>/<registry>/<view> is delegated to
// the operator under the authentication method <auth>.
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)
}
Expand All @@ -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 <account-id>/<registry>/<view>.
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 <account-id>/<registry>/<view>.
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 {
Expand All @@ -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
}
4 changes: 2 additions & 2 deletions confdb/confdb_control_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions daemon/api_confdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

0 comments on commit 1e10d53

Please sign in to comment.