File: confdb_control.go

package info (click to toggle)
snapd 2.72-1
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 80,412 kB
  • sloc: sh: 16,506; ansic: 16,211; python: 11,213; makefile: 1,919; exp: 190; awk: 58; xml: 22
file content (354 lines) | stat: -rw-r--r-- 8,970 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
// -*- Mode: Go; indent-tabs-mode: t -*-

/*
 * Copyright (C) 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 <http://www.gnu.org/licenses/>.
 *
 */

package confdb

import (
	"errors"
	"fmt"
	"regexp"
	"sort"
	"strings"
)

var (
	validAccountID = regexp.MustCompile("^(?:[a-z0-9A-Z]{32}|[-a-z0-9]{2,28})$")
)

// Control holds the delegations done by the device to operators.
type Control struct {
	// the key is the operator ID
	operators map[string]*operator
}

// Delegate delegates the given views with the provided authentication methods to the operator.
func (cc *Control) Delegate(operatorID string, views, authMeth []string) error {
	if !validAccountID.MatchString(operatorID) {
		return fmt.Errorf("invalid operator ID: %s", operatorID)
	}

	if cc.operators == nil {
		cc.operators = make(map[string]*operator)
	}

	op, ok := cc.operators[operatorID]
	if !ok {
		op = &operator{ID: operatorID}
	}

	err := op.delegate(views, authMeth)
	if err != nil {
		return err
	}

	cc.operators[operatorID] = op
	return nil
}

// Undelegate withdraws access to the views that have been delegated with the provided authentication methods.
func (cc *Control) Undelegate(operatorID string, views, authMeth []string) error {
	op, ok := cc.operators[operatorID]
	if !ok {
		return nil // nothing is delegated to this operator
	}

	err := op.undelegate(views, authMeth)
	if err != nil {
		return err
	}

	if len(op.Delegations) == 0 {
		delete(cc.operators, operatorID)
	}

	return nil
}

// IsDelegated checks if the view is delegated to the operator with the given authentication methods.
func (cc *Control) IsDelegated(operatorID, view string, authMeth []string) (bool, error) {
	op, ok := cc.operators[operatorID]
	if !ok {
		return false, nil // nothing is delegated to this operator
	}

	return op.isDelegated(view, authMeth)
}

// Groups returns the groups in a format that can be used to assemble the next revision
// of the confdb-control assertion.
func (cc *Control) Groups() []any {
	// Group operators by authentication and views
	// i.e. authentication > views > operator-ids
	authMap := map[authentication]map[viewRef][]string{}
	// auths will accumulate the authentication methods in use
	var auths []authentication

	// Group operators by auth and view
	for _, op := range cc.operators {
		for view, auth := range op.Delegations {
			if _, exists := authMap[auth]; !exists {
				authMap[auth] = map[viewRef][]string{}
				auths = append(auths, auth)
			}

			authMap[auth][view] = append(authMap[auth][view], op.ID)
		}
	}

	// Sort auths for consistent output
	sort.Slice(auths, func(i, j int) bool { return auths[i] < auths[j] })

	var groups []any
	for _, auth := range auths {
		authStrs := auth.toStrings()

		// Group by unique operator sets
		opGroups := make(map[string][]string)

		for view, ops := range authMap[auth] {
			sort.Strings(ops)
			key := strings.Join(ops, ",")
			opGroups[key] = append(opGroups[key], view.String())
		}

		for ops, views := range opGroups {
			sort.Strings(views)
			groups = append(groups, map[string]any{
				"operators":       toAnySlice(strings.Split(ops, ",")),
				"authentications": toAnySlice(authStrs),
				"views":           toAnySlice(views),
			})
		}
	}

	return groups
}

// Clone returns a deep copy of Control.
func (cc Control) Clone() Control {
	clone := Control{operators: make(map[string]*operator)}

	for id, op := range cc.operators {
		if op != nil {
			clone.operators[id] = op.clone()
		}
	}

	return clone
}

// authentication limits what keys can be used to sign messages used to remotely manage confdbs.
type authentication uint8

const (
	// Only the operator's keys can be used to sign the messages.
	OperatorKey authentication = 1 << iota
	// Messages can be signed on behalf of the operator by the store.
	Store
)

const (
	allAuth authentication = OperatorKey | Store
)

// newAuthentication converts []string to authentication and validates it.
func newAuthentication(authMeth []string) (authentication, error) {
	var auth authentication
	for _, method := range authMeth {
		switch method {
		case "operator-key":
			auth |= OperatorKey
		case "store":
			auth |= Store
		default:
			return 0, fmt.Errorf("invalid authentication method: %s", method)
		}
	}
	return auth, nil
}

// Convert authentication to a sorted []string.
func (a authentication) toStrings() []string {
	var keys []string
	if a&OperatorKey == OperatorKey {
		keys = append(keys, "operator-key")
	}

	if a&Store == Store {
		keys = append(keys, "store")
	}

	return keys
}

// operator holds the delegations for a single operator.
type operator struct {
	ID          string
	Delegations map[viewRef]authentication
}

// viewRef holds the reference to account/confdb/view as parsed from the
// confdb-control assertion.
type viewRef struct {
	Account string
	Confdb  string
	View    string
}

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

// convertToViewRefs converts []string to []viewRef and validates it.
func convertToViewRefs(views []string) ([]viewRef, error) {
	var result []viewRef
	for _, view := range views {
		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)
		}

		result = append(result, viewRef{Account: account, Confdb: confdb, View: viewName})
	}

	return result, nil
}

// delegate grants remote access to the views under the given auth.
func (op *operator) delegate(views, authMeth []string) error {
	if len(authMeth) == 0 {
		return errors.New(`cannot delegate: "authentications" must be a non-empty list`)
	}

	auth, err := newAuthentication(authMeth)
	if err != nil {
		return fmt.Errorf("cannot delegate: %w", err)
	}

	if len(views) == 0 {
		return errors.New(`cannot delegate: "views" must be a non-empty list`)
	}

	viewRefs, err := convertToViewRefs(views)
	if err != nil {
		return fmt.Errorf("cannot delegate: %w", err)
	}

	if op.Delegations == nil {
		op.Delegations = map[viewRef]authentication{}
	}

	for _, viewRef := range viewRefs {
		op.Delegations[viewRef] |= auth
	}

	return nil
}

// undelegate withdraws remote access to the views that have been delegated with the given auth.
func (op *operator) undelegate(views, authMeth []string) error {
	auth := allAuth // if no authentication is provided, revoke all auth methods
	var err error
	if len(authMeth) > 0 {
		auth, err = newAuthentication(authMeth)
		if err != nil {
			return fmt.Errorf("cannot undelegate: %w", err)
		}
	}

	var viewRefs []viewRef
	if len(views) == 0 {
		// if no views are provided, operate on all views
		for viewRef := range op.Delegations {
			viewRefs = append(viewRefs, viewRef)
		}
	} else {
		viewRefs, err = convertToViewRefs(views)
		if err != nil {
			return fmt.Errorf("cannot undelegate: %w", err)
		}
	}

	for _, viewRef := range viewRefs {
		if _, exists := op.Delegations[viewRef]; exists {
			op.Delegations[viewRef] &= ^auth

			if op.Delegations[viewRef] == 0 { // all remote access removed
				delete(op.Delegations, viewRef)
			}
		}
	}

	return nil
}

// isDelegated checks if the view is delegated to the operator with the given auth.
func (op *operator) isDelegated(view string, authMeth []string) (bool, error) {
	viewRefs, err := convertToViewRefs([]string{view})
	if err != nil {
		return false, err
	}

	auth, err := newAuthentication(authMeth)
	if err != nil {
		return false, err
	}

	delegatedWith := op.Delegations[viewRefs[0]]
	return delegatedWith&auth == auth, nil
}

// clone returns a deep copy of the operator.
func (op operator) clone() *operator {
	clone := &operator{
		ID:          op.ID,
		Delegations: make(map[viewRef]authentication),
	}

	for view, auth := range op.Delegations {
		clone.Delegations[view] = auth
	}

	return clone
}

// toAnySlice converts []string to []any.
func toAnySlice(strs []string) []any {
	result := make([]any, len(strs))
	for i, str := range strs {
		result[i] = str
	}
	return result
}