• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mindersec / minder / 12613632569

04 Jan 2025 08:51PM UTC coverage: 55.318% (+0.2%) from 55.145%
12613632569

Pull #5100

github

web-flow
Merge 690366ff2 into a6d9b2295
Pull Request #5100: Add support for Get Profile Status By ID in Cli and Api

117 of 188 new or added lines in 1 file covered. (62.23%)

3 existing lines in 2 files now uncovered.

17097 of 30907 relevant lines covered (55.32%)

37.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.85
/internal/authz/authz.go
1
// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors
2
// SPDX-License-Identifier: Apache-2.0
3

4
// Package authz provides the authorization utilities for minder
5
package authz
6

7
import (
8
        "context"
9
        _ "embed"
10
        "encoding/json"
11
        "errors"
12
        "fmt"
13
        "net/http"
14
        "strings"
15

16
        "github.com/google/uuid"
17
        fgasdk "github.com/openfga/go-sdk"
18
        fgaclient "github.com/openfga/go-sdk/client"
19
        "github.com/openfga/go-sdk/credentials"
20
        "github.com/rs/zerolog"
21
        "k8s.io/client-go/transport"
22

23
        "github.com/mindersec/minder/internal/auth/jwt"
24
        minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
25
        srvconfig "github.com/mindersec/minder/pkg/config/server"
26
)
27

28
var (
29
        // ErrStoreNotFound denotes the error where the store wasn't found via the
30
        // given configuration.
31
        ErrStoreNotFound = errors.New("Store not found")
32

33
        //go:embed model/minder.generated.json
34
        authzModel string
35
)
36

37
// ClientWrapper is a wrapper for the OpenFgaClient.
38
// It is used to provide a common interface for the client and a way to
39
// refresh authentication to the authz provider when needed.
40
type ClientWrapper struct {
41
        cfg *srvconfig.AuthzConfig
42
        cli *fgaclient.OpenFgaClient
43
        l   *zerolog.Logger
44
}
45

46
var _ Client = &ClientWrapper{}
47

48
// NewAuthzClient returns a new AuthzClientWrapper
49
func NewAuthzClient(cfg *srvconfig.AuthzConfig, l *zerolog.Logger) (Client, error) {
3✔
50
        if err := cfg.Validate(); err != nil {
3✔
51
                return nil, err
×
52
        }
×
53

54
        cliWrap := &ClientWrapper{
3✔
55
                cfg: cfg,
3✔
56
                l:   l,
3✔
57
        }
3✔
58

3✔
59
        if err := cliWrap.initAuthzClient(); err != nil {
3✔
60
                return nil, err
×
61
        }
×
62

63
        return cliWrap, nil
3✔
64
}
65

66
// initAuthzClient initializes the authz client based on the configuration.
67
// Note that this assumes the configuration has already been validated.
68
func (a *ClientWrapper) initAuthzClient() error {
3✔
69
        clicfg := &fgaclient.ClientConfiguration{
3✔
70
                ApiUrl: a.cfg.ApiUrl,
3✔
71
                Credentials: &credentials.Credentials{
3✔
72
                        // We use our own bearer auth round tripper so we can refresh the token
3✔
73
                        Method: credentials.CredentialsMethodNone,
3✔
74
                },
3✔
75
        }
3✔
76

3✔
77
        if a.cfg.StoreID != "" {
3✔
78
                clicfg.StoreId = a.cfg.StoreID
×
79
        }
×
80

81
        if a.cfg.Auth.Method == "token" {
3✔
82
                rt, err := transport.NewBearerAuthWithRefreshRoundTripper("", a.cfg.Auth.Token.TokenPath, http.DefaultTransport)
×
83
                if err != nil {
×
84
                        return fmt.Errorf("failed to create bearer auth round tripper: %w", err)
×
85
                }
×
86

87
                clicfg.HTTPClient = &http.Client{
×
88
                        Transport: rt,
×
89
                }
×
90
        }
91

92
        cli, err := fgaclient.NewSdkClient(clicfg)
3✔
93
        if err != nil {
3✔
94
                return fmt.Errorf("failed to create SDK client: %w", err)
×
95
        }
×
96

97
        a.cli = cli
3✔
98
        return nil
3✔
99
}
100

101
// PrepareForRun initializes the authz client based on the configuration.
102
// This is handy when migrations have already been done and helps us auto-discover
103
// the store ID and model.
104
func (a *ClientWrapper) PrepareForRun(ctx context.Context) error {
2✔
105
        storeID, err := a.findStoreByName(ctx)
2✔
106
        if err != nil {
2✔
107
                return fmt.Errorf("unable to find authz store: %w", err)
×
108
        }
×
109

110
        err = a.cli.SetStoreId(storeID)
2✔
111
        if err != nil {
2✔
112
                return fmt.Errorf("unable to store authz ID: %w", err)
×
113
        }
×
114

115
        modelID, err := a.findLatestModel(ctx)
2✔
116
        if err != nil {
2✔
117
                return fmt.Errorf("unable to find authz model: %w", err)
×
118
        }
×
119

120
        if err := a.cli.SetAuthorizationModelId(modelID); err != nil {
2✔
121
                return fmt.Errorf("unable to set authz model: %w", err)
×
122
        }
×
123

124
        return nil
2✔
125
}
126

127
// StoreIDProvided returns true if the store ID was provided in the configuration
128
func (a *ClientWrapper) StoreIDProvided() bool {
3✔
129
        return a.cfg.StoreID != ""
3✔
130
}
3✔
131

132
// MigrateUp runs the authz migrations. For OpenFGA this means creating the store
133
// and writing the authz model.
134
func (a *ClientWrapper) MigrateUp(ctx context.Context) error {
3✔
135
        if !a.StoreIDProvided() {
6✔
136
                if err := a.ensureAuthzStore(ctx); err != nil {
3✔
137
                        return err
×
138
                }
×
139
        }
140

141
        m, err := a.writeModel(ctx)
3✔
142
        if err != nil {
3✔
143
                return fmt.Errorf("error while writing authz model: %w", err)
×
144
        }
×
145

146
        a.l.Printf("Wrote authz model %s\n", m)
3✔
147

3✔
148
        return nil
3✔
149
}
150

151
func (a *ClientWrapper) ensureAuthzStore(ctx context.Context) error {
3✔
152
        storeName := a.cfg.StoreName
3✔
153
        storeID, err := a.findStoreByName(ctx)
3✔
154
        if err != nil && !errors.Is(err, ErrStoreNotFound) {
3✔
155
                return err
×
156
        } else if errors.Is(err, ErrStoreNotFound) {
6✔
157
                a.l.Printf("Creating authz store %s\n", storeName)
3✔
158
                id, err := a.createStore(ctx)
3✔
159
                if err != nil {
3✔
160
                        return err
×
161
                }
×
162
                a.l.Printf("Created authz store %s/%s\n", id, storeName)
3✔
163
                err = a.cli.SetStoreId(id)
3✔
164
                if err != nil {
3✔
165
                        return fmt.Errorf("unable to store authz ID: %w", err)
×
166
                }
×
167
                return nil
3✔
168
        }
169

170
        a.l.Printf("Not creating store. Found store with name '%s' and ID '%s'.\n",
×
171
                storeName, storeID)
×
172

×
173
        err = a.cli.SetStoreId(storeID)
×
174
        if err != nil {
×
175
                return fmt.Errorf("unable to store authz ID: %w", err)
×
176
        }
×
177
        return nil
×
178
}
179

180
// findStoreByName returns the store ID for the configured store name
181
func (a *ClientWrapper) findStoreByName(ctx context.Context) (string, error) {
5✔
182
        stores, err := a.cli.ListStores(ctx).Execute()
5✔
183
        if err != nil {
5✔
184
                return "", fmt.Errorf("error while listing authz stores: %w", err)
×
185
        }
×
186

187
        // TODO: We might want to handle pagination here.
188
        for _, store := range stores.Stores {
7✔
189
                if store.Name == a.cfg.StoreName {
4✔
190
                        return store.Id, nil
2✔
191
                }
2✔
192
        }
193

194
        return "", ErrStoreNotFound
3✔
195
}
196

197
// createStore creates a new store with the configured name
198
func (a *ClientWrapper) createStore(ctx context.Context) (string, error) {
3✔
199
        st, err := a.cli.CreateStore(ctx).Body(fgaclient.ClientCreateStoreRequest{
3✔
200
                Name: a.cfg.StoreName,
3✔
201
        }).Execute()
3✔
202
        if err != nil {
3✔
203
                return "", fmt.Errorf("error while creating authz store: %w", err)
×
204
        }
×
205

206
        return st.Id, nil
3✔
207
}
208

209
// findLatestModel returns the latest authz model ID
210
func (a *ClientWrapper) findLatestModel(ctx context.Context) (string, error) {
2✔
211
        resp, err := a.cli.ReadLatestAuthorizationModel(ctx).Execute()
2✔
212
        if err != nil {
2✔
213
                return "", fmt.Errorf("error while reading authz model: %w", err)
×
214
        }
×
215

216
        return resp.AuthorizationModel.Id, nil
2✔
217
}
218

219
// writeModel writes the authz model to the configured store
220
func (a *ClientWrapper) writeModel(ctx context.Context) (string, error) {
3✔
221
        var body fgasdk.WriteAuthorizationModelRequest
3✔
222
        if err := json.Unmarshal([]byte(authzModel), &body); err != nil {
3✔
223
                return "", fmt.Errorf("failed to unmarshal authz model: %w", err)
×
224
        }
×
225

226
        data, err := a.cli.WriteAuthorizationModel(ctx).Body(body).Execute()
3✔
227
        if err != nil {
3✔
228
                return "", fmt.Errorf("error while writing authz model: %w", err)
×
229
        }
×
230

231
        return data.GetAuthorizationModelId(), nil
3✔
232
}
233

234
// Check checks if the user is authorized to perform the given action on the
235
// given project.
236
func (a *ClientWrapper) Check(ctx context.Context, action string, project uuid.UUID) error {
8✔
237
        // TODO: set ClientCheckOptions like in
8✔
238
        // https://openfga.dev/docs/getting-started/perform-check#02-calling-check-api
8✔
239
        options := fgaclient.ClientCheckOptions{}
8✔
240
        userString := getUserForTuple(jwt.GetUserSubjectFromContext(ctx))
8✔
241
        body := fgaclient.ClientCheckRequest{
8✔
242
                User:     userString,
8✔
243
                Relation: action,
8✔
244
                Object:   getProjectForTuple(project),
8✔
245
        }
8✔
246
        result, err := a.cli.Check(ctx).Options(options).Body(body).Execute()
8✔
247
        if err != nil {
8✔
248
                return fmt.Errorf("OpenFGA error for %s: %w", userString, err)
×
249
        }
×
250
        if result.Allowed != nil && *result.Allowed {
12✔
251
                return nil
4✔
252
        }
4✔
253
        return ErrNotAuthorized
4✔
254
}
255

256
// Write persists the given role for the given user and project
257
func (a *ClientWrapper) Write(ctx context.Context, user string, role Role, project uuid.UUID) error {
4✔
258
        return a.write(ctx, fgasdk.TupleKey{
4✔
259
                User:     getUserForTuple(user),
4✔
260
                Relation: role.String(),
4✔
261
                Object:   getProjectForTuple(project),
4✔
262
        })
4✔
263
}
4✔
264

265
// Adopt writes a relationship between the parent and child projects
266
func (a *ClientWrapper) Adopt(ctx context.Context, parent, child uuid.UUID) error {
×
267
        return a.write(ctx, fgasdk.TupleKey{
×
268
                User:     getProjectForTuple(parent),
×
269
                Relation: "parent",
×
270
                Object:   getProjectForTuple(child),
×
271
        })
×
272
}
×
273

274
func (a *ClientWrapper) write(ctx context.Context, t fgasdk.TupleKey) error {
4✔
275
        resp, err := a.cli.WriteTuples(ctx).Options(fgaclient.ClientWriteOptions{}).
4✔
276
                Body([]fgasdk.TupleKey{t}).Execute()
4✔
277
        if err != nil && strings.Contains(err.Error(), "already exists") {
4✔
278
                return nil
×
279
        } else if err != nil {
4✔
280
                return fmt.Errorf("unable to persist authorization tuple: %w", err)
×
281
        }
×
282

283
        for _, w := range resp.Writes {
8✔
284
                if w.Error != nil {
4✔
285
                        return fmt.Errorf("unable to persist authorization tuple: %w", w.Error)
×
286
                }
×
287
        }
288

289
        return nil
4✔
290
}
291

292
// Delete removes the given role for the given user and project
293
func (a *ClientWrapper) Delete(ctx context.Context, user string, role Role, project uuid.UUID) error {
1✔
294
        return a.doDelete(ctx, getUserForTuple(user), role.String(), getProjectForTuple(project))
1✔
295
}
1✔
296

297
// Orphan removes the relationship between the parent and child projects
298
func (a *ClientWrapper) Orphan(ctx context.Context, parent, child uuid.UUID) error {
×
299
        return a.doDelete(ctx, getProjectForTuple(parent), "parent", getProjectForTuple(child))
×
300
}
×
301

302
// doDelete wraps the OpenFGA DeleteTuples call and handles edge cases as needed. It takes
303
// the user, role, and project as tuple-formatted strings.
304
func (a *ClientWrapper) doDelete(ctx context.Context, user string, role string, project string) error {
3✔
305
        resp, err := a.cli.DeleteTuples(ctx).Options(fgaclient.ClientWriteOptions{}).Body([]fgasdk.TupleKeyWithoutCondition{
3✔
306
                {
3✔
307
                        User:     user,
3✔
308
                        Relation: role,
3✔
309
                        Object:   project,
3✔
310
                },
3✔
311
        }).Execute()
3✔
312
        if err != nil && strings.Contains(err.Error(), "cannot delete a tuple which does not exist") {
3✔
UNCOV
313
                return nil
×
314
        } else if err != nil {
3✔
315
                return fmt.Errorf("unable to remove authorization tuple: %w", err)
×
316
        }
×
317

318
        for _, w := range resp.Deletes {
6✔
319
                if w.Error != nil {
3✔
320
                        return fmt.Errorf("unable to remove authorization tuple: %w", w.Error)
×
321
                }
×
322
        }
323

324
        return nil
3✔
325
}
326

327
// DeleteUser removes all tuples for the given user
328
func (a *ClientWrapper) DeleteUser(ctx context.Context, user string) error {
1✔
329
        for role := range AllRolesDescriptions {
6✔
330
                listresp, err := a.cli.ListObjects(ctx).Body(fgaclient.ClientListObjectsRequest{
5✔
331
                        Type:     "project",
5✔
332
                        Relation: role.String(),
5✔
333
                        User:     getUserForTuple(user),
5✔
334
                }).Execute()
5✔
335
                if err != nil {
5✔
336
                        return fmt.Errorf("unable to list authorization tuples: %w", err)
×
337
                }
×
338

339
                for _, obj := range listresp.GetObjects() {
7✔
340
                        if err := a.doDelete(ctx, getUserForTuple(user), role.String(), obj); err != nil {
2✔
341
                                return err
×
342
                        }
×
343
                }
344
        }
345

346
        return nil
1✔
347
}
348

349
// AssignmentsToProject lists the current role assignments that are scoped to a project
350
func (a *ClientWrapper) AssignmentsToProject(ctx context.Context, project uuid.UUID) ([]*minderv1.RoleAssignment, error) {
5✔
351
        o := getProjectForTuple(project)
5✔
352
        prjStr := project.String()
5✔
353

5✔
354
        var pagesize int32 = 50
5✔
355
        var contTok *string = nil
5✔
356

5✔
357
        assignments := []*minderv1.RoleAssignment{}
5✔
358

5✔
359
        for {
10✔
360
                resp, err := a.cli.Read(ctx).Options(fgaclient.ClientReadOptions{
5✔
361
                        PageSize:          &pagesize,
5✔
362
                        ContinuationToken: contTok,
5✔
363
                }).Body(fgaclient.ClientReadRequest{
5✔
364
                        Object: &o,
5✔
365
                }).Execute()
5✔
366
                if err != nil {
5✔
367
                        return nil, fmt.Errorf("unable to read authorization tuples: %w", err)
×
368
                }
×
369

370
                for _, t := range resp.GetTuples() {
8✔
371
                        k := t.GetKey()
3✔
372
                        r, err := ParseRole(k.GetRelation())
3✔
373
                        if err != nil {
3✔
374
                                a.l.Err(err).Msg("Found invalid role in authz store")
×
375
                                continue
×
376
                        }
377
                        assignments = append(assignments, &minderv1.RoleAssignment{
3✔
378
                                Subject: getUserFromTuple(k.GetUser()),
3✔
379
                                Role:    r.String(),
3✔
380
                                Project: &prjStr,
3✔
381
                        })
3✔
382
                }
383

384
                if resp.GetContinuationToken() == "" {
10✔
385
                        break
5✔
386
                }
387

388
                contTok = &resp.ContinuationToken
×
389
        }
390

391
        return assignments, nil
5✔
392
}
393

394
// ProjectsForUser lists the projects that the given user has access to
395
func (a *ClientWrapper) ProjectsForUser(ctx context.Context, sub string) ([]uuid.UUID, error) {
4✔
396
        u := getUserForTuple(sub)
4✔
397

4✔
398
        var pagesize int32 = 50
4✔
399
        var contTok *string = nil
4✔
400

4✔
401
        projs := map[string]any{}
4✔
402
        projectObj := "project:"
4✔
403

4✔
404
        for {
8✔
405
                resp, err := a.cli.Read(ctx).Options(fgaclient.ClientReadOptions{
4✔
406
                        PageSize:          &pagesize,
4✔
407
                        ContinuationToken: contTok,
4✔
408
                }).Body(fgaclient.ClientReadRequest{
4✔
409
                        User:   &u,
4✔
410
                        Object: &projectObj,
4✔
411
                }).Execute()
4✔
412
                if err != nil {
4✔
413
                        return nil, fmt.Errorf("unable to read authorization tuples: %w", err)
×
414
                }
×
415

416
                for _, t := range resp.GetTuples() {
7✔
417
                        k := t.GetKey()
3✔
418

3✔
419
                        projs[k.GetObject()] = struct{}{}
3✔
420
                }
3✔
421

422
                if resp.GetContinuationToken() == "" {
8✔
423
                        break
4✔
424
                }
425

426
                contTok = &resp.ContinuationToken
×
427
        }
428

429
        out := []uuid.UUID{}
4✔
430
        for proj := range projs {
7✔
431
                u, err := uuid.Parse(getProjectFromTuple(proj))
3✔
432
                if err != nil {
3✔
433
                        continue
×
434
                }
435

436
                out = append(out, u)
3✔
437

3✔
438
                children, err := a.traverseProjectsForParent(ctx, u)
3✔
439
                if err != nil {
3✔
440
                        return nil, err
×
441
                }
×
442

443
                out = append(out, children...)
3✔
444
        }
445

446
        return out, nil
4✔
447
}
448

449
// traverseProjectsForParent is a recursive function that traverses the project
450
// hierarchy to find all projects that the parent project has access to.
451
func (a *ClientWrapper) traverseProjectsForParent(ctx context.Context, parent uuid.UUID) ([]uuid.UUID, error) {
3✔
452
        projects := []uuid.UUID{}
3✔
453

3✔
454
        resp, err := a.cli.ListObjects(ctx).Body(fgaclient.ClientListObjectsRequest{
3✔
455
                User:     getProjectForTuple(parent),
3✔
456
                Relation: "parent",
3✔
457
                Type:     "project",
3✔
458
        }).Execute()
3✔
459

3✔
460
        if err != nil {
3✔
461
                return nil, fmt.Errorf("unable to read authorization tuples: %w", err)
×
462
        }
×
463

464
        for _, obj := range resp.GetObjects() {
3✔
465
                u, err := uuid.Parse(getProjectFromTuple(obj))
×
466
                if err != nil {
×
467
                        continue
×
468
                }
469
                projects = append(projects, u)
×
470
        }
471

472
        for _, proj := range projects {
3✔
473
                children, err := a.traverseProjectsForParent(ctx, proj)
×
474
                if err != nil {
×
475
                        return nil, err
×
476
                }
×
477
                projects = append(projects, children...)
×
478
        }
479

480
        return projects, nil
3✔
481
}
482

483
func getUserForTuple(user string) string {
24✔
484
        return "user:" + user
24✔
485
}
24✔
486

487
func getProjectForTuple(project uuid.UUID) string {
21✔
488
        return "project:" + project.String()
21✔
489
}
21✔
490

491
func getUserFromTuple(user string) string {
3✔
492
        return strings.TrimPrefix(user, "user:")
3✔
493
}
3✔
494

495
func getProjectFromTuple(project string) string {
3✔
496
        return strings.TrimPrefix(project, "project:")
3✔
497
}
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc