Skip to content

Commit cafac05

Browse files
committed
admin: address PR #673 review (slog key, validation parity, 501 sweep)
Three findings from claude + Gemini review on 70213e0: 1) **Issue 1 — `logUnexpectedSourceError` slog key was "table"** When called for bucket operations the field key was `"table"` but the value was a bucket name. Log queries on `table=` would find spurious bucket-error entries; queries on `bucket=` would miss the audit lines entirely. Renamed the parameter and the slog key to `resource` so the same forensic query works for both resource families. 2) **Gemini security-high + Claude Issue 2 — validation divergence** `handleCreateBucket` only checked `strings.TrimSpace(name) == ""` while the HTTP path's `validateCreateBucketRequest` also rejects whitespace-padded names like `" bucket "`. The forward path would have accepted them, then hit the adapter's `validateS3BucketName` with a less actionable error message — different SPA behaviour depending on whether the request was leader-direct or follower-forwarded. Fix: call `validateCreateBucketRequest(body)` in `handleCreateBucket` exactly like `decodeCreateTableRequest` is shared between the table-side handlers. 3) **Issue 3 — only CREATE_BUCKET tested for nil-BucketsSource → 501** `DELETE_BUCKET` and `PUT_BUCKET_ACL` had identical `if s.buckets == nil` guards but no coverage. Replaced `TestForwardServer_CreateBucket_NoBucketsSourceReturns501` with a table-driven `TestForwardServer_BucketOps_NoBucketsSourceReturns501` sweeping all three operations. A future op added without the nil guard fails CI immediately. Plus a new `TestForwardServer_CreateBucket_RejectsWhitespacePaddedName` that pins the validation-parity fix from #2. Rebased onto the latest `feat/admin-s3-writes` (which now carries the slice 2a review fixes) so the stack stays clean.
1 parent ce4c6ee commit cafac05

2 files changed

Lines changed: 70 additions & 12 deletions

File tree

internal/admin/forward_server.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,15 @@ func (s *ForwardServer) handleCreateBucket(ctx context.Context, principal AuthPr
261261
return rejectForward(http.StatusBadRequest, "invalid_body",
262262
"create-bucket payload has trailing data")
263263
}
264-
if strings.TrimSpace(body.BucketName) == "" {
265-
return rejectForward(http.StatusBadRequest, "invalid_body",
266-
"bucket_name is required")
264+
// Reuse the HTTP handler's validateCreateBucketRequest so the
265+
// forwarded path enforces identical rules — empty / whitespace-
266+
// padded bucket_name produces the same 400 message a leader-
267+
// direct call would, instead of slipping through here and
268+
// hitting the adapter's lower-level validateS3BucketName with
269+
// a less actionable error (Gemini security-high + Claude #2 on
270+
// PR #673).
271+
if err := validateCreateBucketRequest(body); err != nil {
272+
return rejectForward(http.StatusBadRequest, "invalid_body", err.Error())
267273
}
268274
summary, err := s.buckets.AdminCreateBucket(ctx, principal, body)
269275
if err != nil {
@@ -503,12 +509,18 @@ func forwardErrorResponse(op string, err error) *pb.AdminForwardResponse {
503509
// regressions, and logging them at LevelError would drown the
504510
// operational signal. The HTTP path's writeTablesError applies
505511
// the same policy (Codex P2 on PR #635 flagged the silent path).
506-
func (s *ForwardServer) logUnexpectedSourceError(ctx context.Context, op, table, forwardedFrom string, err error) {
512+
//
513+
// The resource argument carries either a Dynamo table name or an
514+
// S3 bucket name, depending on op. The slog key is "resource" so
515+
// log queries do not have to know which resource family produced a
516+
// given line — Claude review on PR #673 caught the prior "table"
517+
// key, which made bucket-error queries miss the audit entries.
518+
func (s *ForwardServer) logUnexpectedSourceError(ctx context.Context, op, resource, forwardedFrom string, err error) {
507519
if isStructuredSourceError(err) {
508520
return
509521
}
510522
s.logger.LogAttrs(ctx, slog.LevelError, "admin_forward_"+op+"_failed",
511-
slog.String("table", table),
523+
slog.String("resource", resource),
512524
slog.String("forwarded_from", forwardedFrom),
513525
slog.String("error", err.Error()),
514526
)

internal/admin/forward_server_test.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -474,19 +474,65 @@ func TestForwardServer_CreateBucket_HappyPath(t *testing.T) {
474474
require.Equal(t, "public-assets", summary.Name)
475475
}
476476

477-
func TestForwardServer_CreateBucket_NoBucketsSourceReturns501(t *testing.T) {
477+
func TestForwardServer_BucketOps_NoBucketsSourceReturns501(t *testing.T) {
478478
// Builds without S3 do not call WithBucketsSource; the leader
479-
// must still reject CREATE_BUCKET cleanly with 501 instead of
480-
// reaching for a nil receiver and panicking.
481-
srv := newForwardServerForTest(&stubTablesSource{tables: map[string]*DynamoTableSummary{}}, fullPrincipalRoleStore())
479+
// must still reject every bucket operation cleanly with 501
480+
// instead of reaching for a nil receiver and panicking. Sweep
481+
// over all three operations so a future op added without a
482+
// nil-receiver guard fails CI immediately (Claude review on
483+
// PR #673 caught the original test only covering CREATE_BUCKET).
484+
cases := []struct {
485+
name string
486+
op pb.AdminOperation
487+
payload []byte
488+
}{
489+
{
490+
name: "create",
491+
op: pb.AdminOperation_ADMIN_OP_CREATE_BUCKET,
492+
payload: mustJSON(t, CreateBucketRequest{BucketName: "x"}),
493+
},
494+
{
495+
name: "delete",
496+
op: pb.AdminOperation_ADMIN_OP_DELETE_BUCKET,
497+
payload: []byte(`{"name":"x"}`),
498+
},
499+
{
500+
name: "put_acl",
501+
op: pb.AdminOperation_ADMIN_OP_PUT_BUCKET_ACL,
502+
payload: []byte(`{"name":"x","acl":"private"}`),
503+
},
504+
}
505+
for _, tc := range cases {
506+
t.Run(tc.name, func(t *testing.T) {
507+
srv := newForwardServerForTest(&stubTablesSource{tables: map[string]*DynamoTableSummary{}}, fullPrincipalRoleStore())
508+
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
509+
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
510+
Operation: tc.op,
511+
Payload: tc.payload,
512+
})
513+
require.NoError(t, err)
514+
require.Equal(t, int32(http.StatusNotImplemented), resp.GetStatusCode())
515+
require.Contains(t, string(resp.GetPayload()), "not_implemented")
516+
})
517+
}
518+
}
519+
520+
func TestForwardServer_CreateBucket_RejectsWhitespacePaddedName(t *testing.T) {
521+
// Validation parity with the HTTP path's
522+
// validateCreateBucketRequest: a name like " bucket " must
523+
// produce the same 400 invalid_body the leader-direct path
524+
// emits, instead of slipping through and hitting the lower-
525+
// level adapter validator with a less actionable error
526+
// (Gemini security-high + Claude #2 on PR #673).
527+
srv := newForwardServerWithBucketsForTest(&stubBucketsSource{}, fullPrincipalRoleStore())
482528
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
483529
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
484530
Operation: pb.AdminOperation_ADMIN_OP_CREATE_BUCKET,
485-
Payload: mustJSON(t, CreateBucketRequest{BucketName: "x"}),
531+
Payload: mustJSON(t, CreateBucketRequest{BucketName: " padded "}),
486532
})
487533
require.NoError(t, err)
488-
require.Equal(t, int32(http.StatusNotImplemented), resp.GetStatusCode())
489-
require.Contains(t, string(resp.GetPayload()), "not_implemented")
534+
require.Equal(t, int32(http.StatusBadRequest), resp.GetStatusCode())
535+
require.Contains(t, string(resp.GetPayload()), "leading or trailing whitespace")
490536
}
491537

492538
func TestForwardServer_CreateBucket_BadJSONReturns400(t *testing.T) {

0 commit comments

Comments
 (0)