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

mindersec / minder / 13283006810

12 Feb 2025 10:14AM UTC coverage: 57.515% (+0.03%) from 57.481%
13283006810

Pull #5418

github

web-flow
Merge 7e7f02f59 into 8aba270eb
Pull Request #5418: build(deps): bump google.golang.org/protobuf from 1.36.4 to 1.36.5 in /tools

18170 of 31592 relevant lines covered (57.51%)

37.64 hits per line

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

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

4
package controlplane
5

6
import (
7
        "context"
8
        "database/sql"
9
        "errors"
10

11
        "github.com/google/uuid"
12
        "github.com/rs/zerolog"
13
        "google.golang.org/grpc"
14
        "google.golang.org/grpc/codes"
15
        "google.golang.org/grpc/status"
16
        "google.golang.org/protobuf/proto"
17
        "google.golang.org/protobuf/types/known/timestamppb"
18

19
        "github.com/mindersec/minder/internal/auth"
20
        "github.com/mindersec/minder/internal/auth/jwt"
21
        "github.com/mindersec/minder/internal/authz"
22
        "github.com/mindersec/minder/internal/db"
23
        "github.com/mindersec/minder/internal/engine/engcontext"
24
        "github.com/mindersec/minder/internal/flags"
25
        "github.com/mindersec/minder/internal/invites"
26
        "github.com/mindersec/minder/internal/util"
27
        minder "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
28
)
29

30
type rpcOptionsKey struct{}
31

32
func getRpcOptions(ctx context.Context) *minder.RpcOptions {
12✔
33
        // nil value default is okay here
12✔
34
        opts, _ := ctx.Value(rpcOptionsKey{}).(*minder.RpcOptions)
12✔
35
        return opts
12✔
36
}
12✔
37

38
// EntityContextProjectInterceptor is a server interceptor that sets up the entity context project
39
func EntityContextProjectInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
40
        handler grpc.UnaryHandler) (any, error) {
8✔
41

8✔
42
        opts := getRpcOptions(ctx)
8✔
43

8✔
44
        if opts.GetTargetResource() == minder.TargetResource_TARGET_RESOURCE_UNSPECIFIED {
9✔
45
                return nil, status.Error(codes.Internal, "cannot perform authorization, because target resource is unspecified")
1✔
46
        }
1✔
47

48
        if opts.GetTargetResource() != minder.TargetResource_TARGET_RESOURCE_PROJECT {
8✔
49
                if !opts.GetNoLog() {
2✔
50
                        zerolog.Ctx(ctx).Info().Msgf("Bypassing setting up context")
1✔
51
                }
1✔
52
                return handler(ctx, req)
1✔
53
        }
54

55
        server, ok := info.Server.(*Server)
6✔
56
        if !ok {
6✔
57
                return nil, status.Errorf(codes.Internal, "error casting serrver for request handling")
×
58
        }
×
59

60
        ctx, err := populateEntityContext(ctx, server.store, server.authzClient, req)
6✔
61
        if err != nil {
9✔
62
                return nil, err
3✔
63
        }
3✔
64

65
        return handler(ctx, req)
3✔
66
}
67

68
// ProjectAuthorizationInterceptor is a server interceptor that checks if a user is authorized on the requested project
69
func ProjectAuthorizationInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
70
        handler grpc.UnaryHandler) (any, error) {
4✔
71

4✔
72
        opts := getRpcOptions(ctx)
4✔
73

4✔
74
        if opts.GetTargetResource() != minder.TargetResource_TARGET_RESOURCE_PROJECT {
6✔
75
                if !opts.GetNoLog() {
4✔
76
                        zerolog.Ctx(ctx).Info().Msgf("Bypassing project authorization")
2✔
77
                }
2✔
78
                return handler(ctx, req)
2✔
79
        }
80

81
        relation := opts.GetRelation()
2✔
82

2✔
83
        relationValue := relation.Descriptor().Values().ByNumber(relation.Number())
2✔
84
        if relationValue == nil {
2✔
85
                return nil, status.Errorf(codes.Internal, "error reading relation value %v", relation)
×
86
        }
×
87
        extension := proto.GetExtension(relationValue.Options(), minder.E_Name)
2✔
88
        relationName, ok := extension.(string)
2✔
89
        if !ok {
2✔
90
                return nil, status.Errorf(codes.Internal, "error getting name for requested relation %v", relation)
×
91
        }
×
92

93
        entityCtx := engcontext.EntityFromContext(ctx)
2✔
94
        server := info.Server.(*Server)
2✔
95

2✔
96
        if err := server.authzClient.Check(ctx, relationName, entityCtx.Project.ID); err != nil {
3✔
97
                zerolog.Ctx(ctx).Error().Err(err).Msg("authorization check failed")
1✔
98
                return nil, util.UserVisibleError(
1✔
99
                        codes.PermissionDenied, "user %q is not authorized to perform this operation on project %q",
1✔
100
                        auth.IdentityFromContext(ctx).Human(), entityCtx.Project.ID)
1✔
101
        }
1✔
102

103
        return handler(ctx, req)
1✔
104
}
105

106
// populateEntityContext populates the project in the entity context, by looking at the proto context or
107
// fetching the default project
108
func populateEntityContext(
109
        ctx context.Context,
110
        store db.Store,
111
        authzClient authz.Client,
112
        req any,
113
) (context.Context, error) {
6✔
114
        projectID, err := getProjectIDFromContext(req)
6✔
115
        if err != nil {
10✔
116
                if errors.Is(err, ErrNoProjectInContext) {
5✔
117
                        projectID, err = getDefaultProjectID(ctx, store, authzClient)
1✔
118
                        if err != nil {
1✔
119
                                return ctx, err
×
120
                        }
×
121
                } else {
3✔
122
                        return ctx, err
3✔
123
                }
3✔
124
        }
125

126
        entityCtx := &engcontext.EntityContext{
3✔
127
                Project: engcontext.Project{
3✔
128
                        ID: projectID,
3✔
129
                },
3✔
130
                Provider: engcontext.Provider{
3✔
131
                        Name: getProviderFromContext(req),
3✔
132
                },
3✔
133
        }
3✔
134

3✔
135
        return engcontext.WithEntityContext(ctx, entityCtx), nil
3✔
136
}
137

138
func getProjectIDFromContext(req any) (uuid.UUID, error) {
6✔
139
        switch req := req.(type) {
6✔
140
        case HasProtoContextV2Compat:
×
141
                return getProjectFromContextV2Compat(req)
×
142
        case HasProtoContextV2:
×
143
                return getProjectFromContextV2(req)
×
144
        case HasProtoContext:
5✔
145
                return getProjectFromContext(req)
5✔
146
        default:
1✔
147
                return uuid.Nil, status.Errorf(codes.Internal, "Error extracting context from request")
1✔
148
        }
149
}
150

151
func getProviderFromContext(req any) string {
3✔
152
        switch req := req.(type) {
3✔
153
        case HasProtoContextV2Compat:
×
154
                if req.GetContextV2().GetProvider() != "" {
×
155
                        return req.GetContextV2().GetProvider()
×
156
                }
×
157
                return req.GetContext().GetProvider()
×
158
        case HasProtoContextV2:
×
159
                return req.GetContext().GetProvider()
×
160
        case HasProtoContext:
3✔
161
                return req.GetContext().GetProvider()
3✔
162
        default:
×
163
                return ""
×
164
        }
165
}
166

167
func getDefaultProjectID(
168
        ctx context.Context,
169
        store db.Store,
170
        authzClient authz.Client,
171
) (uuid.UUID, error) {
1✔
172
        userId := auth.IdentityFromContext(ctx)
1✔
173

1✔
174
        // Not sure if we still need to do this at all, but we only create database users
1✔
175
        // for users registered in the primary ("") provider.
1✔
176
        if userId != nil && userId.String() == userId.UserID {
2✔
177
                _, err := store.GetUserBySubject(ctx, userId.String())
1✔
178
                if err != nil {
1✔
179
                        // Note that we're revealing that the user is not registered in minder
×
180
                        // since the caller has a valid token (this is checked in earlier middleware).
×
181
                        // Therefore, we assume it's safe output that the user is not found.
×
182
                        return uuid.UUID{}, util.UserVisibleError(codes.NotFound, "user not found")
×
183
                }
×
184
        }
185
        prjs, err := authzClient.ProjectsForUser(ctx, userId.String())
1✔
186
        if err != nil {
1✔
187
                return uuid.UUID{}, status.Errorf(codes.Internal, "cannot find projects for user: %v", err)
×
188
        }
×
189

190
        if len(prjs) == 0 {
1✔
191
                return uuid.UUID{}, util.UserVisibleError(codes.PermissionDenied, "User has no role grants in projects")
×
192
        }
×
193

194
        if len(prjs) != 1 {
1✔
195
                return uuid.UUID{}, util.UserVisibleError(codes.PermissionDenied, "Multiple project found, cannot "+
×
196
                        "determine default project. Please explicitly set a project and run the command again.")
×
197
        }
×
198

199
        return prjs[0], nil
1✔
200
}
201

202
// Permissions API
203
// ensure interface implementation
204
var _ minder.PermissionsServiceServer = (*Server)(nil)
205

206
// ListRoles returns the list of available roles for the minder instance
207
func (*Server) ListRoles(_ context.Context, _ *minder.ListRolesRequest) (*minder.ListRolesResponse, error) {
×
208
        resp := minder.ListRolesResponse{
×
209
                Roles: make([]*minder.Role, 0, len(authz.AllRolesDescriptions)),
×
210
        }
×
211
        // Iterate over all roles and add them to the response if they have a description. Skip if they don't.
×
212
        // The roles are sorted by the order in which they are defined in the authz package, i.e. admin, editor, viewer, etc.
×
213
        for _, role := range authz.AllRolesSorted {
×
214
                // Skip roles that don't have a description
×
215
                if authz.AllRolesDescriptions[role] == "" {
×
216
                        continue
×
217
                }
218
                // Add the role to the response
219
                resp.Roles = append(resp.Roles, &minder.Role{
×
220
                        Name:        role.String(),
×
221
                        DisplayName: authz.AllRolesDisplayName[role],
×
222
                        Description: authz.AllRolesDescriptions[role],
×
223
                })
×
224
        }
225
        return &resp, nil
×
226
}
227

228
// ListRoleAssignments returns the list of role assignments for the given project
229
func (s *Server) ListRoleAssignments(
230
        ctx context.Context,
231
        _ *minder.ListRoleAssignmentsRequest,
232
) (*minder.ListRoleAssignmentsResponse, error) {
4✔
233
        invitations := make([]*minder.Invitation, 0)
4✔
234
        // Determine the target project.
4✔
235
        entityCtx := engcontext.EntityFromContext(ctx)
4✔
236
        targetProject := entityCtx.Project.ID
4✔
237

4✔
238
        as, err := s.authzClient.AssignmentsToProject(ctx, targetProject)
4✔
239
        if err != nil {
4✔
240
                return nil, status.Errorf(codes.Internal, "error getting role assignments: %v", err)
×
241
        }
×
242

243
        // Resolve the display names for the subjects
244
        mapIdToDisplay := make(map[string]string, len(as))
4✔
245
        for i := range as {
9✔
246
                identity, err := s.idClient.Resolve(ctx, as[i].Subject)
5✔
247
                if err != nil {
5✔
248
                        // If we can't resolve the subject, report the raw ID value
×
249
                        as[i].DisplayName = as[i].Subject
×
250
                        if mapIdToDisplay[as[i].Subject] == "" {
×
251
                                mapIdToDisplay[as[i].Subject] = as[i].Subject
×
252
                        }
×
253
                        zerolog.Ctx(ctx).Error().Err(err).Msg("error resolving identity")
×
254
                        continue
×
255
                }
256
                as[i].DisplayName = identity.Human()
5✔
257
                as[i].FirstName = identity.FirstName
5✔
258
                as[i].LastName = identity.LastName
5✔
259
                if mapIdToDisplay[as[i].Subject] == "" {
10✔
260
                        mapIdToDisplay[as[i].Subject] = identity.Human()
5✔
261
                }
5✔
262
        }
263

264
        if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
5✔
265
                // Add invitations, which are only stored in the Minder DB
1✔
266
                projectInvites, err := s.store.ListInvitationsForProject(ctx, targetProject)
1✔
267
                if err != nil {
1✔
268
                        // return the information we can and log the error
×
269
                        zerolog.Ctx(ctx).Error().Err(err).Msg("error getting invitations")
×
270
                }
×
271
                for _, i := range projectInvites {
2✔
272
                        invitations = append(invitations, &minder.Invitation{
1✔
273
                                Role:           i.Role,
1✔
274
                                Email:          i.Email,
1✔
275
                                Project:        targetProject.String(),
1✔
276
                                CreatedAt:      timestamppb.New(i.CreatedAt),
1✔
277
                                ExpiresAt:      invites.GetExpireIn7Days(i.UpdatedAt),
1✔
278
                                Expired:        invites.IsExpired(i.UpdatedAt),
1✔
279
                                Sponsor:        i.IdentitySubject,
1✔
280
                                SponsorDisplay: mapIdToDisplay[i.IdentitySubject],
1✔
281
                                // Code is explicitly not returned here
1✔
282
                        })
1✔
283
                }
1✔
284
        }
285

286
        return &minder.ListRoleAssignmentsResponse{
4✔
287
                RoleAssignments: as,
4✔
288
                Invitations:     invitations,
4✔
289
        }, nil
4✔
290
}
291

292
// AssignRole assigns a role to a user on a project.
293
// Note that this assumes that the request has already been authorized.
294
//
295
//nolint:gocyclo  // There's a lot of trivial error handling here
296
func (s *Server) AssignRole(ctx context.Context, req *minder.AssignRoleRequest) (*minder.AssignRoleResponse, error) {
15✔
297
        role := req.GetRoleAssignment().GetRole()
15✔
298
        sub := req.GetRoleAssignment().GetSubject()
15✔
299
        inviteeEmail := req.GetRoleAssignment().GetEmail()
15✔
300

15✔
301
        // Determine the target project.
15✔
302
        entityCtx := engcontext.EntityFromContext(ctx)
15✔
303
        targetProject := entityCtx.Project.ID
15✔
304

15✔
305
        // Ensure user is not updating their own role
15✔
306
        err := isUserSelfUpdating(ctx, sub, inviteeEmail)
15✔
307
        if err != nil {
16✔
308
                return nil, err
1✔
309
        }
1✔
310

311
        // Parse role (this also validates)
312
        authzRole, err := authz.ParseRole(role)
14✔
313
        if err != nil {
14✔
314
                return nil, util.UserVisibleError(codes.InvalidArgument, "%s", err.Error())
×
315
        }
×
316

317
        // Ensure the target project exists
318
        _, err = s.store.GetProjectByID(ctx, targetProject)
14✔
319
        if err != nil {
15✔
320
                // If the project is not found, return an error
1✔
321
                if errors.Is(err, sql.ErrNoRows) {
2✔
322
                        return nil, util.UserVisibleError(codes.InvalidArgument, "target project with ID %s not found", targetProject)
1✔
323
                }
1✔
324
                return nil, status.Errorf(codes.Internal, "error getting project: %v", err)
×
325
        }
326

327
        // Decide if it's an invitation or a role assignment
328
        if sub == "" && inviteeEmail != "" {
14✔
329
                if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
2✔
330
                        invitation, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.Invitation, error) {
2✔
331
                                return s.invites.CreateInvite(ctx, qtx, s.evt, s.cfg.Email, targetProject, authzRole, inviteeEmail)
1✔
332
                        })
1✔
333
                        if err != nil {
1✔
334
                                return nil, err
×
335
                        }
×
336

337
                        return &minder.AssignRoleResponse{
1✔
338
                                // Leaving the role assignment empty as it's an invitation
1✔
339
                                Invitation: invitation,
1✔
340
                        }, nil
1✔
341
                }
342
                return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
×
343
        } else if sub != "" && inviteeEmail == "" {
23✔
344
                identity, err := s.idClient.Resolve(ctx, sub)
11✔
345
                if err != nil || identity == nil {
11✔
346
                        return nil, util.UserVisibleError(codes.NotFound, "could not find identity %q", sub)
×
347
                }
×
348
                isMachine := identity.Provider.String() != ""
11✔
349
                if !isMachine && flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
13✔
350
                        return nil, util.UserVisibleError(codes.Unimplemented, "human users may only be added by invitation")
2✔
351
                }
2✔
352
                if isMachine && !flags.Bool(ctx, s.featureFlags, flags.MachineAccounts) {
10✔
353
                        return nil, util.UserVisibleError(codes.Unimplemented, "machine accounts are not enabled")
1✔
354
                }
1✔
355
                assignment, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.RoleAssignment, error) {
16✔
356
                        return s.roles.CreateRoleAssignment(ctx, qtx, s.authzClient, targetProject, *identity, authzRole)
8✔
357
                })
8✔
358
                if err != nil {
8✔
359
                        return nil, err
×
360
                }
×
361

362
                return &minder.AssignRoleResponse{
8✔
363
                        RoleAssignment: assignment,
8✔
364
                }, nil
8✔
365
        }
366
        return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified")
1✔
367
}
368

369
// RemoveRole removes a role from a user on a project
370
// Note that this assumes that the request has already been authorized.
371
func (s *Server) RemoveRole(ctx context.Context, req *minder.RemoveRoleRequest) (*minder.RemoveRoleResponse, error) {
4✔
372
        role := req.GetRoleAssignment().GetRole()
4✔
373
        sub := req.GetRoleAssignment().GetSubject()
4✔
374
        inviteeEmail := req.GetRoleAssignment().GetEmail()
4✔
375
        // Determine the target project.
4✔
376
        entityCtx := engcontext.EntityFromContext(ctx)
4✔
377
        targetProject := entityCtx.Project.ID
4✔
378

4✔
379
        // Parse role (this also validates)
4✔
380
        authzRole, err := authz.ParseRole(role)
4✔
381
        if err != nil {
4✔
382
                return nil, util.UserVisibleError(codes.InvalidArgument, "%s", err.Error())
×
383
        }
×
384

385
        // Validate the subject and email - decide if it's about removing an invitation or a role assignment
386
        if sub == "" && inviteeEmail != "" {
5✔
387
                if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
2✔
388
                        deletedInvitation, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.Invitation, error) {
2✔
389
                                return s.invites.RemoveInvite(ctx, qtx, s.idClient, targetProject, authzRole, inviteeEmail)
1✔
390
                        })
1✔
391
                        if err != nil {
1✔
392
                                return nil, err
×
393
                        }
×
394

395
                        return &minder.RemoveRoleResponse{
1✔
396
                                Invitation: deletedInvitation,
1✔
397
                        }, nil
1✔
398
                }
399
                return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
×
400
        } else if sub != "" && inviteeEmail == "" {
5✔
401
                // If there's a subject, we assume it's a role assignment
2✔
402
                deletedRoleAssignment, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.RoleAssignment, error) {
4✔
403
                        return s.roles.RemoveRoleAssignment(ctx, qtx, s.authzClient, s.idClient, targetProject, sub, authzRole)
2✔
404
                })
2✔
405
                if err != nil {
2✔
406
                        return nil, err
×
407
                }
×
408
                return &minder.RemoveRoleResponse{
2✔
409
                        RoleAssignment: deletedRoleAssignment,
2✔
410
                }, nil
2✔
411
        }
412
        return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified")
1✔
413
}
414

415
// UpdateRole updates a role for a user on a project
416
func (s *Server) UpdateRole(ctx context.Context, req *minder.UpdateRoleRequest) (*minder.UpdateRoleResponse, error) {
4✔
417
        // For the time being, ensure only one role is updated at a time
4✔
418
        if len(req.GetRoles()) != 1 {
4✔
419
                return nil, util.UserVisibleError(codes.InvalidArgument, "only one role can be updated at a time")
×
420
        }
×
421
        role := req.GetRoles()[0]
4✔
422
        sub := req.GetSubject()
4✔
423
        inviteeEmail := req.GetEmail()
4✔
424

4✔
425
        // Determine the target project.
4✔
426
        entityCtx := engcontext.EntityFromContext(ctx)
4✔
427
        targetProject := entityCtx.Project.ID
4✔
428

4✔
429
        // Ensure user is not updating their own role
4✔
430
        err := isUserSelfUpdating(ctx, sub, inviteeEmail)
4✔
431
        if err != nil {
5✔
432
                return nil, err
1✔
433
        }
1✔
434

435
        // Parse role (this also validates)
436
        authzRole, err := authz.ParseRole(role)
3✔
437
        if err != nil {
3✔
438
                return nil, util.UserVisibleError(codes.InvalidArgument, "%s", err.Error())
×
439
        }
×
440

441
        // Validate the subject and email - decide if it's about updating an invitation or a role assignment
442
        if sub == "" && inviteeEmail != "" {
4✔
443
                if flags.Bool(ctx, s.featureFlags, flags.UserManagement) {
2✔
444
                        updatedInvitation, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.Invitation, error) {
2✔
445
                                return s.invites.UpdateInvite(ctx, qtx, s.evt, s.cfg.Email, targetProject, authzRole, inviteeEmail)
1✔
446
                        })
1✔
447
                        if err != nil {
1✔
448
                                return nil, err
×
449
                        }
×
450

451
                        return &minder.UpdateRoleResponse{
1✔
452
                                Invitations: []*minder.Invitation{
1✔
453
                                        updatedInvitation,
1✔
454
                                },
1✔
455
                        }, nil
1✔
456
                }
457
                return nil, util.UserVisibleError(codes.Unimplemented, "user management is not enabled")
×
458
        } else if sub != "" && inviteeEmail == "" {
3✔
459
                // If there's a subject, we assume it's a role assignment update
1✔
460
                updatedAssignment, err := db.WithTransaction(s.store, func(qtx db.ExtendQuerier) (*minder.RoleAssignment, error) {
2✔
461
                        return s.roles.UpdateRoleAssignment(ctx, qtx, s.authzClient, s.idClient, targetProject, sub, authzRole)
1✔
462
                })
1✔
463
                if err != nil {
1✔
464
                        return nil, err
×
465
                }
×
466

467
                return &minder.UpdateRoleResponse{
1✔
468
                        RoleAssignments: []*minder.RoleAssignment{
1✔
469
                                updatedAssignment,
1✔
470
                        },
1✔
471
                }, nil
1✔
472
        }
473
        return nil, util.UserVisibleError(codes.InvalidArgument, "one of subject or email must be specified")
1✔
474
}
475

476
// isUserSelfUpdating is used to prevent if the user is trying to update their own role
477
func isUserSelfUpdating(ctx context.Context, subject, inviteeEmail string) error {
19✔
478
        if subject != "" {
32✔
479
                if auth.IdentityFromContext(ctx).String() == subject {
13✔
480
                        return util.UserVisibleError(codes.InvalidArgument, "cannot update your own role")
×
481
                }
×
482
        }
483
        if inviteeEmail != "" {
23✔
484
                tokenEmail, err := jwt.GetUserEmailFromContext(ctx)
4✔
485
                if err != nil {
4✔
486
                        return util.UserVisibleError(codes.Internal, "error getting user email from token: %v", err)
×
487
                }
×
488
                if tokenEmail == inviteeEmail {
6✔
489
                        return util.UserVisibleError(codes.InvalidArgument, "cannot update your own role")
2✔
490
                }
2✔
491
        }
492
        return nil
17✔
493
}
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