carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package mail
2
3import (
4 "context"
5 "errors"
6 "reflect"
7 "testing"
8
9 "github.com/aws/aws-sdk-go-v2/aws"
10 "github.com/aws/aws-sdk-go-v2/service/iam"
11 iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
12)
13
14// stubIAM is an iamAPI double that records every call by name, in order,
15// and lets a test script CreateUser and GetUser. Every other method
16// answers success.
17type stubIAM struct {
18 calls []string
19
20 createUser func(*iam.CreateUserInput) (*iam.CreateUserOutput, error)
21 getUser func(*iam.GetUserInput) (*iam.GetUserOutput, error)
22 listUsers func(*iam.ListUsersInput) (*iam.ListUsersOutput, error)
23 putBoundary func(*iam.PutUserPermissionsBoundaryInput) error
24 boundariesOn []string
25
26 createIn *iam.CreateUserInput
27 boundaryIn *iam.PutUserPermissionsBoundaryInput
28 policyIn *iam.PutUserPolicyInput
29}
30
31func (s *stubIAM) CreateUser(_ context.Context, in *iam.CreateUserInput, _ ...func(*iam.Options)) (*iam.CreateUserOutput, error) {
32 s.calls = append(s.calls, "CreateUser")
33 s.createIn = in
34 if s.createUser != nil {
35 return s.createUser(in)
36 }
37 return &iam.CreateUserOutput{User: &iamtypes.User{Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName))}}, nil
38}
39
40func (s *stubIAM) GetUser(_ context.Context, in *iam.GetUserInput, _ ...func(*iam.Options)) (*iam.GetUserOutput, error) {
41 s.calls = append(s.calls, "GetUser")
42 if s.getUser != nil {
43 return s.getUser(in)
44 }
45 return &iam.GetUserOutput{User: &iamtypes.User{Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName))}}, nil
46}
47
48func (s *stubIAM) PutUserPolicy(_ context.Context, in *iam.PutUserPolicyInput, _ ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error) {
49 s.calls = append(s.calls, "PutUserPolicy")
50 s.policyIn = in
51 return &iam.PutUserPolicyOutput{}, nil
52}
53
54func (s *stubIAM) PutUserPermissionsBoundary(_ context.Context, in *iam.PutUserPermissionsBoundaryInput, _ ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error) {
55 s.calls = append(s.calls, "PutUserPermissionsBoundary")
56 s.boundaryIn = in
57 if s.putBoundary != nil {
58 if err := s.putBoundary(in); err != nil {
59 return nil, err
60 }
61 }
62 s.boundariesOn = append(s.boundariesOn, aws.ToString(in.UserName))
63 return &iam.PutUserPermissionsBoundaryOutput{}, nil
64}
65
66func (s *stubIAM) DeleteUserPolicy(context.Context, *iam.DeleteUserPolicyInput, ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error) {
67 s.calls = append(s.calls, "DeleteUserPolicy")
68 return &iam.DeleteUserPolicyOutput{}, nil
69}
70
71func (s *stubIAM) CreateAccessKey(context.Context, *iam.CreateAccessKeyInput, ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error) {
72 s.calls = append(s.calls, "CreateAccessKey")
73 return &iam.CreateAccessKeyOutput{AccessKey: &iamtypes.AccessKey{}}, nil
74}
75
76func (s *stubIAM) UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error) {
77 s.calls = append(s.calls, "UpdateAccessKey")
78 return &iam.UpdateAccessKeyOutput{}, nil
79}
80
81func (s *stubIAM) DeleteAccessKey(context.Context, *iam.DeleteAccessKeyInput, ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error) {
82 s.calls = append(s.calls, "DeleteAccessKey")
83 return &iam.DeleteAccessKeyOutput{}, nil
84}
85
86func (s *stubIAM) ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error) {
87 s.calls = append(s.calls, "ListAccessKeys")
88 return &iam.ListAccessKeysOutput{}, nil
89}
90
91func (s *stubIAM) ListUsers(_ context.Context, in *iam.ListUsersInput, _ ...func(*iam.Options)) (*iam.ListUsersOutput, error) {
92 s.calls = append(s.calls, "ListUsers")
93 if s.listUsers != nil {
94 return s.listUsers(in)
95 }
96 return &iam.ListUsersOutput{}, nil
97}
98
99func (s *stubIAM) DeleteUser(context.Context, *iam.DeleteUserInput, ...func(*iam.Options)) (*iam.DeleteUserOutput, error) {
100 s.calls = append(s.calls, "DeleteUser")
101 return &iam.DeleteUserOutput{}, nil
102}
103
104const (
105 testUserBoundary = "arn:aws:iam::123456789012:policy/carlos-mail-boundary"
106 otherUserBoundary = "arn:aws:iam::123456789012:policy/something-else"
107)
108
109// ensureTestUser runs the real awsProvisioner.EnsureUser against stub with
110// a grant SendPolicyJSON accepts.
111func ensureTestUser(t *testing.T, stub *stubIAM, boundary string) (string, error) {
112 t.Helper()
113 p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: boundary}
114 return p.EnsureUser(context.Background(), "carlos-mail-bag-hm", UserPath,
115 []string{"arn:aws:ses:eu-west-1:123456789012:identity/example.com"}, nil, []string{"example.com"})
116}
117
118// existingUser makes CreateUser report the user already exists, and GetUser
119// return it carrying pb (nil for a user with no boundary).
120func existingUser(stub *stubIAM, pb *iamtypes.AttachedPermissionsBoundary) {
121 stub.createUser = func(*iam.CreateUserInput) (*iam.CreateUserOutput, error) {
122 return nil, &iamtypes.EntityAlreadyExistsException{Message: aws.String("exists")}
123 }
124 stub.getUser = func(in *iam.GetUserInput) (*iam.GetUserOutput, error) {
125 return &iam.GetUserOutput{User: &iamtypes.User{
126 UserName: in.UserName,
127 Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName)),
128 PermissionsBoundary: pb,
129 }}, nil
130 }
131}
132
133// (a) A new user is created under the configured boundary. Without it the
134// console's role can make a /carlos-mail/ user with an admin policy.
135func TestEnsureUserCreatesUnderTheBoundary(t *testing.T) {
136 stub := &stubIAM{}
137 if _, err := ensureTestUser(t, stub, testUserBoundary); err != nil {
138 t.Fatalf("EnsureUser: %v", err)
139 }
140 if stub.createIn == nil || stub.createIn.PermissionsBoundary == nil {
141 t.Fatal("CreateUser was called with NO permissions boundary")
142 }
143 if got := aws.ToString(stub.createIn.PermissionsBoundary); got != testUserBoundary {
144 t.Fatalf("CreateUser PermissionsBoundary = %q, want %q", got, testUserBoundary)
145 }
146 if got := aws.ToString(stub.createIn.Path); got != UserPath {
147 t.Fatalf("CreateUser Path = %q, want %q", got, UserPath)
148 }
149 if want := []string{"CreateUser", "PutUserPolicy"}; !reflect.DeepEqual(stub.calls, want) {
150 t.Fatalf("calls = %v, want %v", stub.calls, want)
151 }
152}
153
154// (b) An existing user with no boundary, or with a different one, gets the
155// configured boundary set, and set BEFORE its policy is written: once the
156// operator conditions PutUserPolicy on iam:PermissionsBoundary, a policy
157// write on an unbounded user is refused.
158func TestEnsureUserBackfillsTheBoundary(t *testing.T) {
159 for _, tc := range []struct {
160 name string
161 pb *iamtypes.AttachedPermissionsBoundary
162 }{
163 {"no boundary", nil},
164 {"empty boundary struct", &iamtypes.AttachedPermissionsBoundary{}},
165 {"another boundary", &iamtypes.AttachedPermissionsBoundary{
166 PermissionsBoundaryArn: aws.String(otherUserBoundary),
167 PermissionsBoundaryType: iamtypes.PermissionsBoundaryAttachmentTypePolicy,
168 }},
169 } {
170 t.Run(tc.name, func(t *testing.T) {
171 stub := &stubIAM{}
172 existingUser(stub, tc.pb)
173 arn, err := ensureTestUser(t, stub, testUserBoundary)
174 if err != nil {
175 t.Fatalf("EnsureUser: %v", err)
176 }
177 if arn != "arn:aws:iam::123456789012:user/carlos-mail/carlos-mail-bag-hm" {
178 t.Fatalf("arn = %q", arn)
179 }
180 if want := []string{"CreateUser", "GetUser", "PutUserPermissionsBoundary", "PutUserPolicy"}; !reflect.DeepEqual(stub.calls, want) {
181 t.Fatalf("calls = %v, want %v", stub.calls, want)
182 }
183 if got := aws.ToString(stub.boundaryIn.PermissionsBoundary); got != testUserBoundary {
184 t.Fatalf("PutUserPermissionsBoundary PermissionsBoundary = %q, want %q", got, testUserBoundary)
185 }
186 if got := aws.ToString(stub.boundaryIn.UserName); got != "carlos-mail-bag-hm" {
187 t.Fatalf("PutUserPermissionsBoundary UserName = %q", got)
188 }
189 })
190 }
191}
192
193// (c) An existing user that already carries the boundary costs no extra
194// call.
195func TestEnsureUserLeavesACorrectBoundaryAlone(t *testing.T) {
196 stub := &stubIAM{}
197 existingUser(stub, &iamtypes.AttachedPermissionsBoundary{
198 PermissionsBoundaryArn: aws.String(testUserBoundary),
199 PermissionsBoundaryType: iamtypes.PermissionsBoundaryAttachmentTypePolicy,
200 })
201 if _, err := ensureTestUser(t, stub, testUserBoundary); err != nil {
202 t.Fatalf("EnsureUser: %v", err)
203 }
204 if want := []string{"CreateUser", "GetUser", "PutUserPolicy"}; !reflect.DeepEqual(stub.calls, want) {
205 t.Fatalf("calls = %v, want %v", stub.calls, want)
206 }
207}
208
209// A failed backfill stops the walk before the policy write: the user must
210// never get a fresh policy while it still has no ceiling.
211func TestEnsureUserBackfillFailureWritesNoPolicy(t *testing.T) {
212 stub := &stubIAM{}
213 existingUser(stub, nil)
214 p := &awsProvisioner{iam: &failingBoundaryIAM{stubIAM: stub}, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
215 _, err := p.EnsureUser(context.Background(), "carlos-mail-bag-hm", UserPath,
216 []string{"arn:aws:ses:eu-west-1:123456789012:identity/example.com"}, nil, []string{"example.com"})
217 if err == nil {
218 t.Fatal("EnsureUser succeeded although PutUserPermissionsBoundary failed")
219 }
220 for _, c := range stub.calls {
221 if c == "PutUserPolicy" {
222 t.Fatalf("PutUserPolicy ran after a failed backfill: calls = %v", stub.calls)
223 }
224 }
225}
226
227type failingBoundaryIAM struct{ *stubIAM }
228
229func (f *failingBoundaryIAM) PutUserPermissionsBoundary(context.Context, *iam.PutUserPermissionsBoundaryInput, ...func(*iam.Options)) (*iam.PutUserPermissionsBoundaryOutput, error) {
230 f.calls = append(f.calls, "PutUserPermissionsBoundary")
231 return nil, errors.New("AccessDenied")
232}
233
234// Every path that creates or refreshes a sending user goes through
235// EnsureUser, so the boundary has to hold there even for a provisioner
236// built without the constructor: no IAM call at all.
237func TestEnsureUserRefusesWithoutABoundary(t *testing.T) {
238 stub := &stubIAM{}
239 _, err := ensureTestUser(t, stub, "")
240 if !errors.Is(err, ErrNoUserBoundary) {
241 t.Fatalf("err = %v, want ErrNoUserBoundary", err)
242 }
243 if len(stub.calls) != 0 {
244 t.Fatalf("IAM was called without a boundary: %v", stub.calls)
245 }
246}
247
248// (d) The constructor refuses a missing or malformed boundary before it
249// loads AWS config or calls STS, so this test makes no network call.
250func TestNewAWSProvisionerRefusesABadBoundary(t *testing.T) {
251 for _, b := range []string{
252 "",
253 "carlos-mail-boundary",
254 "arn:aws:iam::123456789012:role/carlos-mail-boundary",
255 "arn:aws:iam::123456789012:policy/",
256 "arn:aws:iam::123456789012:policy/*",
257 "arn:aws:s3:::policy/x",
258 "arn:aws:iam:eu-west-1:123456789012:policy/x",
259 "arn:aws:iam::not-an-account:policy/example",
260 "arn:aws:iam::12345678901:policy/example",
261 "arn:aws:iam::1234567890123:policy/example",
262 "arn:aws:iam::aws:policy/AdministratorAccess",
263 "arn:aws:iam::123456789012:policy/exa\tmple",
264 "arn:aws:iam::123456789012:policy/example\n",
265 "arn:aws:iam::123456789012:policy/exa mple",
266 "arn:aws:iam::123456789012:policy/exa?mple",
267 "arn:aws:iam::123456789012:policy//example",
268 "arn:aws:iam::123456789012:policy/carlos/",
269 "arn:AWS:iam::123456789012:policy/example",
270 "\x00arn:aws:iam::123456789012:policy/example",
271 } {
272 p, err := NewAWSProvisioner(context.Background(), "eu-west-1", b)
273 if !errors.Is(err, ErrNoUserBoundary) {
274 t.Errorf("NewAWSProvisioner(boundary %q) err = %v, want ErrNoUserBoundary", b, err)
275 }
276 if p != nil {
277 t.Errorf("NewAWSProvisioner(boundary %q) returned a provisioner", b)
278 }
279 }
280}
281
282func TestCheckUserBoundaryAcceptsPolicyARNs(t *testing.T) {
283 for _, b := range []string{
284 testUserBoundary,
285 "arn:aws:iam::123456789012:policy/carlos/mail-boundary",
286 "arn:aws-us-gov:iam::123456789012:policy/carlos-mail-boundary",
287 "arn:aws-cn:iam::123456789012:policy/a/b/c+=,.@_-name",
288 } {
289 if err := CheckUserBoundary(b); err != nil {
290 t.Errorf("CheckUserBoundary(%q) = %v, want nil", b, err)
291 }
292 }
293}
294
295// Rotation reaches MintKey without EnsureUser, so MintKey itself backfills:
296// no key is minted for a user without the boundary.
297func TestMintKeyBackfillsTheBoundary(t *testing.T) {
298 for _, tc := range []struct {
299 name string
300 pb *iamtypes.AttachedPermissionsBoundary
301 want []string
302 }{
303 {"unbounded", nil, []string{"GetUser", "PutUserPermissionsBoundary", "CreateAccessKey"}},
304 {"another boundary", &iamtypes.AttachedPermissionsBoundary{PermissionsBoundaryArn: aws.String(otherUserBoundary)},
305 []string{"GetUser", "PutUserPermissionsBoundary", "CreateAccessKey"}},
306 {"bounded", &iamtypes.AttachedPermissionsBoundary{PermissionsBoundaryArn: aws.String(testUserBoundary)},
307 []string{"GetUser", "CreateAccessKey"}},
308 } {
309 t.Run(tc.name, func(t *testing.T) {
310 stub := &stubIAM{}
311 existingUser(stub, tc.pb)
312 p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
313 if _, _, err := p.MintKey(context.Background(), "carlos-mail-bag-hm"); err != nil {
314 t.Fatalf("MintKey: %v", err)
315 }
316 if !reflect.DeepEqual(stub.calls, tc.want) {
317 t.Fatalf("calls = %v, want %v", stub.calls, tc.want)
318 }
319 if tc.pb == nil && aws.ToString(stub.boundaryIn.PermissionsBoundary) != testUserBoundary {
320 t.Fatalf("boundary set = %q, want %q", aws.ToString(stub.boundaryIn.PermissionsBoundary), testUserBoundary)
321 }
322 })
323 }
324}
325
326func TestMintKeyRefusesWhenTheBackfillFails(t *testing.T) {
327 stub := &stubIAM{putBoundary: func(*iam.PutUserPermissionsBoundaryInput) error { return errors.New("AccessDenied") }}
328 existingUser(stub, nil)
329 p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
330 if _, _, err := p.MintKey(context.Background(), "carlos-mail-bag-hm"); err == nil {
331 t.Fatal("MintKey succeeded although the boundary could not be set")
332 }
333 for _, c := range stub.calls {
334 if c == "CreateAccessKey" {
335 t.Fatalf("a key was minted for an unbounded user: calls = %v", stub.calls)
336 }
337 }
338}
339
340// sweepStub serves users over two ListUsers pages and answers GetUser from
341// boundaries (absent = no boundary).
342func sweepStub(t *testing.T, pages [][]string, boundaries map[string]string) *stubIAM {
343 t.Helper()
344 stub := &stubIAM{}
345 stub.listUsers = func(in *iam.ListUsersInput) (*iam.ListUsersOutput, error) {
346 if aws.ToString(in.PathPrefix) != UserPath {
347 t.Fatalf("ListUsers PathPrefix = %q, want %q", aws.ToString(in.PathPrefix), UserPath)
348 }
349 page := 0
350 if in.Marker != nil {
351 page = 1
352 }
353 out := &iam.ListUsersOutput{}
354 for _, n := range pages[page] {
355 out.Users = append(out.Users, iamtypes.User{UserName: aws.String(n)})
356 }
357 if page+1 < len(pages) {
358 out.IsTruncated, out.Marker = true, aws.String("next")
359 }
360 return out, nil
361 }
362 stub.getUser = func(in *iam.GetUserInput) (*iam.GetUserOutput, error) {
363 u := &iamtypes.User{UserName: in.UserName, Arn: aws.String("arn:aws:iam::123456789012:user/carlos-mail/" + aws.ToString(in.UserName))}
364 if b, ok := boundaries[aws.ToString(in.UserName)]; ok {
365 u.PermissionsBoundary = &iamtypes.AttachedPermissionsBoundary{PermissionsBoundaryArn: aws.String(b)}
366 }
367 return &iam.GetUserOutput{User: u}, nil
368 }
369 return stub
370}
371
372// The sweep bounds every unbounded or wrongly bounded user under the path,
373// across pages, and leaves bounded ones alone.
374func TestSweepUserBoundariesBoundsEveryUser(t *testing.T) {
375 stub := sweepStub(t,
376 [][]string{{"carlos-mail-bag-a", "carlos-mail-bag-b"}, {"carlos-mail-bag-c", "carlos-mail-bag-d+s+laptop"}},
377 map[string]string{"carlos-mail-bag-b": testUserBoundary, "carlos-mail-bag-c": otherUserBoundary})
378 p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
379 res, err := p.SweepUserBoundaries(context.Background())
380 if err != nil {
381 t.Fatalf("SweepUserBoundaries: %v", err)
382 }
383 if res.Total != 4 || res.Bounded != 1 || len(res.Failed) != 0 {
384 t.Fatalf("result = %+v, want Total 4, Bounded 1, no failures", res)
385 }
386 want := []string{"carlos-mail-bag-a", "carlos-mail-bag-c", "carlos-mail-bag-d+s+laptop"}
387 if !reflect.DeepEqual(res.Fixed, want) || !reflect.DeepEqual(stub.boundariesOn, want) {
388 t.Fatalf("fixed = %v, boundaries set on %v, want %v", res.Fixed, stub.boundariesOn, want)
389 }
390 if aws.ToString(stub.boundaryIn.PermissionsBoundary) != testUserBoundary {
391 t.Fatalf("boundary set = %q", aws.ToString(stub.boundaryIn.PermissionsBoundary))
392 }
393}
394
395// A user the sweep cannot fix or read is reported, not swallowed, and the
396// sweep goes on to the rest.
397func TestSweepUserBoundariesReportsFailures(t *testing.T) {
398 stub := sweepStub(t, [][]string{{"carlos-mail-bag-a", "carlos-mail-bag-b", "carlos-mail-bag-c"}}, nil)
399 stub.putBoundary = func(in *iam.PutUserPermissionsBoundaryInput) error {
400 if aws.ToString(in.UserName) == "carlos-mail-bag-a" {
401 return errors.New("AccessDenied")
402 }
403 return nil
404 }
405 read := stub.getUser
406 stub.getUser = func(in *iam.GetUserInput) (*iam.GetUserOutput, error) {
407 if aws.ToString(in.UserName) == "carlos-mail-bag-b" {
408 return nil, errors.New("Throttling")
409 }
410 return read(in)
411 }
412 p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
413 res, err := p.SweepUserBoundaries(context.Background())
414 if err != nil {
415 t.Fatalf("SweepUserBoundaries: %v", err)
416 }
417 if res.Total != 3 || !reflect.DeepEqual(res.Fixed, []string{"carlos-mail-bag-c"}) || len(res.Failed) != 2 {
418 t.Fatalf("result = %+v, want 3 total, c fixed, a and b failed", res)
419 }
420 if res.Failed[0].User != "carlos-mail-bag-a" || res.Failed[1].User != "carlos-mail-bag-b" || res.Failed[0].Err == nil || res.Failed[1].Err == nil {
421 t.Fatalf("failed = %+v", res.Failed)
422 }
423}
424
425// A failed listing is an error: the counts cannot claim every user.
426func TestSweepUserBoundariesListFailureIsAnError(t *testing.T) {
427 stub := &stubIAM{listUsers: func(*iam.ListUsersInput) (*iam.ListUsersOutput, error) {
428 return nil, errors.New("AccessDenied: iam:ListUsers")
429 }}
430 p := &awsProvisioner{iam: stub, account: "123456789012", region: "eu-west-1", boundary: testUserBoundary}
431 if _, err := p.SweepUserBoundaries(context.Background()); err == nil {
432 t.Fatal("SweepUserBoundaries succeeded although ListUsers failed")
433 }
434}
435