Skip to content

Commit 9aa4bbf

Browse files
authored
feat(controlplane): domain based allow-listing (#563)
Signed-off-by: Miguel Martinez Trivino <[email protected]>
1 parent 5c96736 commit 9aa4bbf

File tree

5 files changed

+112
-31
lines changed

5 files changed

+112
-31
lines changed

app/controlplane/configs/samples/config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ auth:
1717
# Optional allow list
1818
# allow_list:
1919
20+
# - @my-domain.com # This will allow any email from this domain
2021

2122
# CAS server where the controlplane will push artifacts to
2223
cas_server:

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

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

app/controlplane/internal/conf/conf.proto

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2024 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -15,11 +15,11 @@
1515

1616
syntax = "proto3";
1717

18-
option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf;conf";
19-
18+
import "credentials/v1/config.proto";
2019
import "google/protobuf/duration.proto";
2120
import "validate/validate.proto";
22-
import "credentials/v1/config.proto";
21+
22+
option go_package = "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf;conf";
2323

2424
message Bootstrap {
2525
Server server = 1;
@@ -62,7 +62,7 @@ message ReferrerSharedIndex {
6262
// If the shared, public index feature is enabled
6363
bool enabled = 1;
6464
// list of organizations uuids that are allowed to appear in the shared referrer index
65-
// think of it as a list of trusted publishers
65+
// think of it as a list of trusted publishers
6666
repeated string allowed_orgs = 2;
6767
}
6868

@@ -105,6 +105,8 @@ message Data {
105105
message Auth {
106106
// Authentication creates a JWT that uses this secret for signing
107107
string generated_jws_hmac_secret = 2;
108+
// allow_list is a list of allowed email addresses or domains
109+
// for example ["@chainloop.dev", "[email protected]"]
108110
repeated string allow_list = 3;
109111
string cas_robot_account_private_key_path = 4;
110112
OIDC oidc = 6;

app/controlplane/internal/usercontext/allowlist_middleware.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2024 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -17,6 +17,8 @@ package usercontext
1717

1818
import (
1919
"context"
20+
"fmt"
21+
"strings"
2022

2123
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
2224
"github.com/go-kratos/kratos/v2/middleware"
@@ -37,12 +39,9 @@ func CheckUserInAllowList(allowList []string) middleware.Middleware {
3739
}
3840

3941
// If there are not items in the allowList we allow all users
40-
var allow bool
41-
for _, e := range allowList {
42-
if e == user.Email {
43-
allow = true
44-
break
45-
}
42+
allow, err := inAllowList(allowList, user.Email)
43+
if err != nil {
44+
return nil, v1.ErrorAllowListErrorNotInList("error checking user in allowList: %v", err)
4645
}
4746

4847
if !allow {
@@ -53,3 +52,38 @@ func CheckUserInAllowList(allowList []string) middleware.Middleware {
5352
}
5453
}
5554
}
55+
56+
func inAllowList(allowList []string, email string) (bool, error) {
57+
for _, allowListEntry := range allowList {
58+
// it's a direct email match
59+
if allowListEntry == email {
60+
return true, nil
61+
}
62+
63+
// Check if the entry is a domain and the email is part of it
64+
// extract the domain from the allowList entry
65+
// i.e if the entry is @cyberdyne.io, we get cyberdyne.io
66+
domainComponent := strings.Split(allowListEntry, "@")
67+
if len(domainComponent) != 2 {
68+
return false, fmt.Errorf("invalid domain entry: %q", allowListEntry)
69+
}
70+
71+
// it's not a domain since it contains an username, then continue
72+
if domainComponent[0] != "" {
73+
continue
74+
}
75+
76+
// Compare the domains
77+
emailComponents := strings.Split(email, "@")
78+
if len(emailComponents) != 2 {
79+
return false, fmt.Errorf("invalid email: %q", email)
80+
}
81+
82+
// check if against a potential domain entry in the allowList
83+
if emailComponents[1] == domainComponent[1] {
84+
return true, nil
85+
}
86+
}
87+
88+
return false, nil
89+
}

app/controlplane/internal/usercontext/allowlist_middleware_test.go

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2024 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -24,42 +24,84 @@ import (
2424
)
2525

2626
func TestCheckUserInAllowList(t *testing.T) {
27-
u := &User{Email: "[email protected]", ID: "124"}
27+
const email = "[email protected]"
28+
allowList := []string{
29+
30+
31+
// it can also contain domains
32+
"@cyberdyne.io",
33+
"@dyson-industries.io",
34+
}
35+
2836
testCases := []struct {
2937
name string
3038
allowList []string
31-
user *User
39+
email string
3240
wantErr bool
3341
}{
3442
{
3543
name: "empty allow list",
36-
user: u,
44+
email: email,
3745
allowList: []string{},
3846
},
3947
{
4048
name: "user not in allow list",
41-
user: u,
42-
allowList: []string{"[email protected]"},
49+
email: email,
50+
allowList: []string{"[email protected]"},
4351
wantErr: true,
4452
},
4553
{
4654
name: "context missing, no user loaded",
47-
allowList: []string{"[email protected]"},
55+
allowList: allowList,
4856
wantErr: true,
4957
},
5058
{
5159
name: "user in allow list",
52-
user: u,
53-
allowList: []string{"[email protected]"},
60+
email: email,
61+
allowList: allowList,
62+
},
63+
{
64+
name: "user in one of the valid domains",
65+
66+
allowList: allowList,
67+
},
68+
{
69+
name: "user in one of the valid domains",
70+
71+
allowList: allowList,
72+
},
73+
{
74+
name: "and can use modifiers",
75+
76+
allowList: allowList,
77+
},
78+
{
79+
name: "it needs to be an email",
80+
email: "dyson-industries.io",
81+
allowList: allowList,
82+
wantErr: true,
83+
},
84+
{
85+
name: "domain position is important",
86+
email: "dyson-industries.io@john",
87+
allowList: allowList,
88+
wantErr: true,
89+
},
90+
{
91+
name: "and can't be typosquated",
92+
93+
allowList: allowList,
94+
wantErr: true,
5495
},
5596
}
5697

5798
for _, tc := range testCases {
5899
t.Run(tc.name, func(t *testing.T) {
59100
m := CheckUserInAllowList(tc.allowList)
60101
ctx := context.Background()
61-
if tc.user != nil {
62-
ctx = WithCurrentUser(ctx, tc.user)
102+
if tc.email != "" {
103+
u := &User{Email: tc.email, ID: "124"}
104+
ctx = WithCurrentUser(ctx, u)
63105
}
64106

65107
_, err := m(emptyHandler)(ctx, nil)

0 commit comments

Comments
 (0)