Skip to content

Commit c95b180

Browse files
migmartrijavirln
andauthored
feat: improvements in allowlist middleware (#975)
Signed-off-by: Miguel Martinez Trivino <[email protected]> Signed-off-by: Javier Rodriguez <[email protected]> Co-authored-by: Javier Rodriguez <[email protected]>
1 parent d0c902c commit c95b180

File tree

7 files changed

+329
-136
lines changed

7 files changed

+329
-136
lines changed

app/cli/main.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ func errorInfo(err error, logger zerolog.Logger) (string, int) {
6363
msg = "you need to enable a CAS backend first. Refer to `chainloop cas-backend` command or contact your administrator."
6464
case v1.IsCasBackendErrorReasonInvalid(err):
6565
msg = "the CAS backend you provided is invalid. Refer to `chainloop cas-backend update` command or contact your administrator."
66-
case v1.IsAllowListErrorNotInList(err):
67-
msg = "your user is not part of the allow-list. Please contact your administrator."
6866
case isWrappedErr(st, jwtMiddleware.ErrTokenExpired):
6967
msg = "your authentication token has expired, please run chainloop auth login again"
7068
case isWrappedErr(st, jwtMiddleware.ErrMissingJwtToken):

app/controlplane/configs/config.devel.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ auth:
5353

5454
# Private key used to sign the JWTs meant to be consumed by the CAS
5555
cas_robot_account_private_key_path: "../../devel/devkeys/cas.pem"
56+
# allow_list:
57+
# rules: ["@chainloop.local"]
58+
# selected_routes: ["/controlplane.v1.WorkflowRunService/List"]
59+
# custom_message: "you need to require access here http://foo.com"
60+
5661

5762
# referrer_shared_index:
5863
# enabled: true

app/controlplane/configs/samples/config.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@ auth:
1616
cas_robot_account_private_key_path: "./configs/devkeys/cas.private.key.devel"
1717
# Optional allow list
1818
# allow_list:
19-
20-
# - @my-domain.com # This will allow any email from this domain
19+
# rules:
20+
21+
# - @my-domain.com # This will allow any email from this domain
22+
# Optional selected routes, empty means all of them
23+
# selected_routes: ["/controlplane.v1.WorkflowRunService/List"]
24+
# Optional custom message
25+
# custom_message: "you need to require access here http://foo.com"
2126

2227
# CAS server where the controlplane will push artifacts to
2328
cas_server:

app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go

Lines changed: 170 additions & 81 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/internal/conf/controlplane/config/v1/conf.proto

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ syntax = "proto3";
1818
package controlplane.config.v1;
1919

2020
import "buf/validate/validate.proto";
21+
import "controlplane/v1/response_messages.proto";
2122
import "credentials/v1/config.proto";
2223
import "google/protobuf/duration.proto";
23-
import "controlplane/v1/response_messages.proto";
2424

2525
option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1;conf";
2626

@@ -114,9 +114,7 @@ message Data {
114114
message Auth {
115115
// Authentication creates a JWT that uses this secret for signing
116116
string generated_jws_hmac_secret = 2;
117-
// allow_list is a list of allowed email addresses or domains
118-
// for example ["@chainloop.dev", "[email protected]"]
119-
repeated string allow_list = 3;
117+
AllowList allow_list = 3;
120118
string cas_robot_account_private_key_path = 4;
121119
OIDC oidc = 6;
122120

@@ -126,6 +124,16 @@ message Auth {
126124
string client_secret = 3;
127125
string redirect_url_scheme = 4;
128126
}
127+
128+
message AllowList {
129+
// allow_list is a list of allowed email addresses or domains
130+
// for example ["@chainloop.dev", "[email protected]"]
131+
repeated string rules = 1 [(buf.validate.field).string.min_len = 1];
132+
// Custom message to show when a user is not allowed
133+
string custom_message = 2;
134+
// The list of routes that will be affected by this middleware, by default all of them
135+
repeated string selected_routes = 3;
136+
}
129137
}
130138

131139
message CA {
@@ -143,7 +151,7 @@ message CA {
143151
// OnboardingSpec is a configuration to automatically onboard users in organizations with specific roles
144152
message OnboardingSpec {
145153
// Name of the organization
146-
string name = 1 [(buf.validate.field).string.min_len = 1];;
154+
string name = 1 [(buf.validate.field).string.min_len = 1];
147155
// Role to assign to the user
148156
controlplane.v1.MembershipRole role = 2;
149157
}

app/controlplane/internal/usercontext/allowlist_middleware.go

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,17 @@ import (
2121
"strings"
2222

2323
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
24+
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
2425
"github.com/go-kratos/kratos/v2/middleware"
26+
"github.com/go-kratos/kratos/v2/transport"
2527
)
2628

2729
// Middleware that checks that the user is defined in the allow list
28-
func CheckUserInAllowList(allowList []string) middleware.Middleware {
30+
func CheckUserInAllowList(allowList *conf.Auth_AllowList) middleware.Middleware {
2931
return func(handler middleware.Handler) middleware.Handler {
3032
return func(ctx context.Context, req interface{}) (interface{}, error) {
31-
if len(allowList) == 0 {
33+
// Allowlist disabled
34+
if allowList == nil || len(allowList.GetRules()) == 0 {
3235
return handler(ctx, req)
3336
}
3437

@@ -38,21 +41,43 @@ func CheckUserInAllowList(allowList []string) middleware.Middleware {
3841
return nil, v1.ErrorAllowListErrorNotInList("user not found")
3942
}
4043

44+
// Skip if we have explicitly set some routes and the current request is not part of them
45+
if len(allowList.GetSelectedRoutes()) > 0 && !selectedRoute(ctx, allowList.GetSelectedRoutes()) {
46+
return handler(ctx, req)
47+
}
48+
4149
// If there are not items in the allowList we allow all users
42-
allow, err := inAllowList(allowList, user.Email)
50+
allow, err := inAllowList(allowList.GetRules(), user.Email)
4351
if err != nil {
4452
return nil, v1.ErrorAllowListErrorNotInList("error checking user in allowList: %v", err)
4553
}
4654

4755
if !allow {
48-
return nil, v1.ErrorAllowListErrorNotInList("user %q not in the allowList", user.Email)
56+
msg := fmt.Sprintf("user %q not in the allowList", user.Email)
57+
if allowList.GetCustomMessage() != "" {
58+
msg = allowList.GetCustomMessage()
59+
}
60+
61+
return nil, v1.ErrorAllowListErrorNotInList(msg)
4962
}
5063

5164
return handler(ctx, req)
5265
}
5366
}
5467
}
5568

69+
func selectedRoute(ctx context.Context, selectedRoutes []string) bool {
70+
if info, ok := transport.FromServerContext(ctx); ok {
71+
for _, route := range selectedRoutes {
72+
if info.Operation() == route {
73+
return true
74+
}
75+
}
76+
}
77+
78+
return false
79+
}
80+
5681
func inAllowList(allowList []string, email string) (bool, error) {
5782
for _, allowListEntry := range allowList {
5883
// it's a direct email match

app/controlplane/internal/usercontext/allowlist_middleware_test.go

Lines changed: 105 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,45 @@ import (
2020
"testing"
2121

2222
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
23+
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
24+
"github.com/go-kratos/kratos/v2/transport"
2325
"github.com/stretchr/testify/assert"
2426
)
2527

28+
// mockTransport is a gRPC transport.
29+
type mockTransport struct {
30+
operation string
31+
}
32+
33+
// Kind returns the transport kind.
34+
func (tr *mockTransport) Kind() transport.Kind {
35+
return transport.KindGRPC
36+
}
37+
38+
// Endpoint returns the transport endpoint.
39+
func (tr *mockTransport) Endpoint() string {
40+
return ""
41+
}
42+
43+
// Operation returns the transport operation.
44+
func (tr *mockTransport) Operation() string {
45+
return tr.operation
46+
}
47+
48+
// RequestHeader returns the request header.
49+
func (tr *mockTransport) RequestHeader() transport.Header {
50+
return nil
51+
}
52+
53+
// ReplyHeader returns the reply header.
54+
func (tr *mockTransport) ReplyHeader() transport.Header {
55+
return nil
56+
}
57+
2658
func TestCheckUserInAllowList(t *testing.T) {
2759
const email = "[email protected]"
28-
allowList := []string{
60+
61+
defaultRules := []string{
2962
3063
3164
// it can also contain domains
@@ -34,80 +67,110 @@ func TestCheckUserInAllowList(t *testing.T) {
3467
}
3568

3669
testCases := []struct {
37-
name string
38-
allowList []string
39-
email string
40-
wantErr bool
70+
name string
71+
rules []string
72+
selectedRoutes []string
73+
runningRoute string
74+
email string
75+
wantErr bool
76+
customErrMsg string
4177
}{
4278
{
43-
name: "empty allow list",
44-
email: email,
45-
allowList: []string{},
79+
name: "empty allow list",
80+
email: email,
81+
},
82+
{
83+
name: "user not in allow list",
84+
email: email,
85+
rules: []string{"[email protected]"},
86+
wantErr: true,
87+
},
88+
{
89+
name: "user not allowed to access the route",
90+
91+
runningRoute: "/foo/bar",
92+
selectedRoutes: []string{"/foo/bar"},
93+
wantErr: true,
94+
},
95+
{
96+
name: "route not in selected routes",
97+
98+
runningRoute: "/foo/bar",
99+
selectedRoutes: []string{"/bar/foo", "/request-access", "/forbidden/route"},
46100
},
47101
{
48-
name: "user not in allow list",
49-
email: email,
50-
allowList: []string{"[email protected]"},
51-
wantErr: true,
102+
name: "return custom error message",
103+
email: email,
104+
rules: []string{"[email protected]"},
105+
wantErr: true,
106+
customErrMsg: "custom error message",
52107
},
53108
{
54-
name: "context missing, no user loaded",
55-
allowList: allowList,
56-
wantErr: true,
109+
name: "context missing, no user loaded",
110+
wantErr: true,
57111
},
58112
{
59-
name: "user in allow list",
60-
email: email,
61-
allowList: allowList,
113+
name: "user in allow list",
114+
email: email,
62115
},
63116
{
64-
name: "user in one of the valid domains",
65-
66-
allowList: allowList,
117+
name: "user in one of the valid domains",
118+
67119
},
68120
{
69-
name: "user in one of the valid domains",
70-
71-
allowList: allowList,
121+
name: "user in one of the valid domains",
122+
72123
},
73124
{
74-
name: "and can use modifiers",
75-
76-
allowList: allowList,
125+
name: "and can use modifiers",
126+
77127
},
78128
{
79-
name: "it needs to be an email",
80-
email: "dyson-industries.io",
81-
allowList: allowList,
82-
wantErr: true,
129+
name: "it needs to be an email",
130+
email: "dyson-industries.io",
131+
wantErr: true,
83132
},
84133
{
85-
name: "domain position is important",
86-
email: "dyson-industries.io@john",
87-
allowList: allowList,
88-
wantErr: true,
134+
name: "domain position is important",
135+
email: "dyson-industries.io@john",
136+
wantErr: true,
89137
},
90138
{
91-
name: "and can't be typosquated",
92-
93-
allowList: allowList,
94-
wantErr: true,
139+
name: "and can't be typosquated",
140+
141+
wantErr: true,
95142
},
96143
}
97144

98145
for _, tc := range testCases {
146+
tc := tc
99147
t.Run(tc.name, func(t *testing.T) {
100-
m := CheckUserInAllowList(tc.allowList)
148+
allowList := &conf.Auth_AllowList{
149+
Rules: defaultRules,
150+
CustomMessage: tc.customErrMsg,
151+
SelectedRoutes: tc.selectedRoutes,
152+
}
153+
154+
if tc.rules != nil {
155+
allowList.Rules = tc.rules
156+
}
157+
158+
m := CheckUserInAllowList(allowList)
101159
ctx := context.Background()
102160
if tc.email != "" {
103-
u := &User{Email: tc.email, ID: "124"}
104-
ctx = WithCurrentUser(ctx, u)
161+
ctx = WithCurrentUser(ctx, &User{Email: tc.email, ID: "124"})
162+
}
163+
if tc.runningRoute != "" {
164+
ctx = transport.NewServerContext(ctx, &mockTransport{operation: tc.runningRoute})
105165
}
106166

107167
_, err := m(emptyHandler)(ctx, nil)
108168

109169
if tc.wantErr {
110170
assert.True(t, v1.IsAllowListErrorNotInList(err))
171+
if tc.customErrMsg != "" {
172+
assert.Contains(t, err.Error(), tc.customErrMsg)
173+
}
111174
} else {
112175
assert.NoError(t, err)
113176
}

0 commit comments

Comments
 (0)