Skip to content

Commit 12e8c11

Browse files
authored
admin: read-only S3 bucket endpoints (P2 slice 1) (#658)
P2 slice 1 of [docs/design/2026_04_24_proposed_admin_dashboard.md](https://github.com/bootjp/elastickv/blob/main/docs/design/2026_04_24_proposed_admin_dashboard.md). Read-only S3 admin endpoints land first so the SPA's S3List and S3Detail pages stop hitting 404. Write paths (POST/PUT/DELETE/ACL) ship in slice 2 together with AdminForward integration; until then they reply 405. ## Summary - **`*adapter.S3Server.AdminListBuckets` / `AdminDescribeBucket`** — SigV4-bypass read methods. Share `loadBucketMetaAt` + the metadata-prefix scan with the SigV4 path, so a SigV4 `listBuckets` and the admin dashboard cannot drift. - **`internal/admin/buckets_source.go`** — `BucketsSource` interface + `BucketSummary` DTO + sentinel errors (`ErrBucketsForbidden` / `ErrBucketsNotLeader` / `ErrBucketsNotFound` / `ErrBucketsAlreadyExists`). `ErrBucketsForbidden` is wired in this slice; the others are reserved for slice 2 to keep the error vocabulary additive. - **`internal/admin/s3_handler.go`** — `S3Handler` with `handleList` (paginated) + `handleDescribe` (404 for missing). Sub-paths under `/buckets/{name}/` (the future `/acl`) return 404 here so a SPA bug pointed at this build cannot accidentally hit the describe path with a `"{name}/acl"` string. - **Shared `list_pagination.go`** — centralises base64url cursor + limit parsing/clamping so the Dynamo and S3 handlers cannot diverge on validation rules. Drops the now-redundant `parseDynamoListLimit` / `decodeDynamoNextToken` / `encodeDynamoNextToken` from `dynamo_handler.go` in favour of `parseListPaginationParams` / `decodeListNextToken` / `encodeListNextToken`. - **`apiRouteTable` in `server.go`** — bundles the precomposed middleware chains and dispatches by URL prefix. `buildAPIMux`'s body went from 13 cyclomatic branches to 6, leaving headroom for the next resource family (SQS queues, etc.) to land without another refactor. - **Production wiring** — `main_s3.go`'s `startS3Server` now returns `*adapter.S3Server`; `runtimeServerRunner` stores it; `startAdminFromFlags` accepts it and threads `bucketsBridge` → `admin.BucketsSource` → `ServerDeps.Buckets`. ## What is NOT in this PR - `AdminCreateBucket` / `AdminPutBucketAcl` / `AdminDeleteBucket` on the adapter and their HTTP/AdminForward counterparts. Slice 2. - `RoleStore` plumb-through for S3. Slice 2 (read-only is fine for any authenticated session today; the write paths will need it). - `forwarded_from` audit trail for S3 admin writes. Slice 2 — needs the `pb.AdminOperation` enum extended. - The merge-freeze-acceptable changes to `proto/admin_forward.proto`. Slice 2. ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `golangci-lint run` (admin + main + adapter packages: 0 issues) - [x] `go test ./internal/admin/ -count=1 -race` — 13 new handler tests pass - [x] `go test . -count=1 -race` — main package passes - [x] `go test -run "TestS3Server_Admin" ./adapter/ -count=1 -race` — 4 new adapter tests pass - [ ] `go test ./adapter/` times out at 120s due to a pre-existing flake (verified earlier on PR #648 against `main` — unrelated to this branch) - [ ] End-to-end smoke against a 3-node cluster — needs the next slice + the SPA PR (#649) to merge before there's anything for the SPA to render ## Acceptance criteria coverage (Section 4.1) | Endpoint | This PR | |---|---| | `GET /admin/api/v1/s3/buckets` | ✓ | | `POST /admin/api/v1/s3/buckets` | ⏳ slice 2 | | `GET /admin/api/v1/s3/buckets/{name}` | ✓ | | `PUT /admin/api/v1/s3/buckets/{name}/acl` | ⏳ slice 2 | | `DELETE /admin/api/v1/s3/buckets/{name}` | ⏳ slice 2 | ## Self-review (5 lenses) 1. **Data loss**: No FSM / Raft / Pebble path changes. `AdminListBuckets` / `AdminDescribeBucket` are read-only and use the same `ScanAt` / `loadBucketMetaAt` as the SigV4 path. 2. **Concurrency**: No new shared state. The handler holds only the immutable `BucketsSource` interface; the bridge holds only the immutable `*S3Server`. `pinReadTS` is the same pattern the SigV4 path uses for snapshot stability. 3. **Performance**: One additional metadata scan per `GET /admin/api/v1/s3/buckets`, identical to the SigV4 listBuckets. Pagination caps at 1000 buckets per page (silent clamp on oversize). No hot-path changes. 4. **Data consistency**: Read-only; no commit-ts ordering risk. Admin reads use the same lease-read window as the SigV4 path. 5. **Test coverage**: 13 handler tests + 4 adapter tests + the existing dynamo/cluster test surfaces still pass after the shared-helper refactor. The pagination cursor round-trip test pins the wire format so a future cursor-encoding change cannot ship without breaking the test. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added S3 bucket admin API endpoints to list all buckets and retrieve detailed bucket information, including creation timestamps. * Implemented pagination support with cursor-based next_token for browsing large bucket collections. * Added proper authorization checks and error handling for admin bucket operations (403 for forbidden, 404 for not found, 500 for errors). <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 1e0a924 + 1bbd1da commit 12e8c11

15 files changed

Lines changed: 1270 additions & 127 deletions

adapter/s3_admin.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package adapter
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"sort"
7+
8+
"github.com/bootjp/elastickv/internal/s3keys"
9+
"github.com/bootjp/elastickv/store"
10+
"github.com/cockroachdb/errors"
11+
)
12+
13+
// adminBucketScanPage is the per-iteration ScanAt page size used by
14+
// AdminListBuckets. Smaller than s3MaxKeys is unnecessary —
15+
// ScanAt's per-call memory budget is already this size on the
16+
// SigV4 listBuckets path — but we use it as a named constant so the
17+
// loop's intent is explicit. AdminListBuckets accumulates pages
18+
// until the prefix is exhausted, so the total returned size is
19+
// bounded by the cluster's bucket count rather than this knob.
20+
const adminBucketScanPage = 1000
21+
22+
// AdminBucketSummary is the bucket-level information the admin
23+
// dashboard surfaces. It deliberately projects only the fields the
24+
// dashboard needs so the package's wire-format types
25+
// (s3BucketMeta, s3ListBucketsResult) stay internal.
26+
//
27+
// CreatedAtHLC is the same physical-time-bearing HLC the bucket
28+
// metadata persists; the admin HTTP handler formats it for the SPA.
29+
// ACL is the canned-ACL string ("private" / "public-read") — the
30+
// admin layer does not expand it into the AWS ACL XML grant tree
31+
// because the dashboard renders the canned form directly.
32+
type AdminBucketSummary struct {
33+
Name string
34+
ACL string
35+
CreatedAtHLC uint64
36+
Generation uint64
37+
Region string
38+
Owner string
39+
}
40+
41+
// AdminListBuckets returns every S3-style bucket this server knows
42+
// about, in lexicographic order (the metadata-prefix scan natural
43+
// ordering). Intended for the in-process admin listener as the
44+
// SigV4-free counterpart to the listBuckets HTTP handler.
45+
//
46+
// Unlike the SigV4 path (which intentionally caps each call at
47+
// s3MaxKeys = 1000 because the AWS API is page-based), the admin
48+
// dashboard's pagination is implemented at the handler layer, which
49+
// expects this method to return the full set. We loop the per-page
50+
// ScanAt until the metadata prefix is exhausted — same pattern as
51+
// scanAllByPrefixAt on the Dynamo side (Codex P1 + Claude Issue 1
52+
// on PR #658).
53+
//
54+
// Returns an empty slice (not nil) when no buckets exist so JSON
55+
// callers see `[]` instead of `null`.
56+
func (s *S3Server) AdminListBuckets(ctx context.Context) ([]AdminBucketSummary, error) {
57+
readTS := s.readTS()
58+
readPin := s.pinReadTS(readTS)
59+
defer readPin.Release()
60+
prefix := []byte(s3keys.BucketMetaPrefix)
61+
end := prefixScanEnd(prefix)
62+
start := bytes.Clone(prefix)
63+
out := make([]AdminBucketSummary, 0, adminBucketScanPage)
64+
for {
65+
kvs, err := s.store.ScanAt(ctx, start, end, adminBucketScanPage, readTS)
66+
if err != nil {
67+
return nil, errors.Wrap(err, "admin list buckets: scan metadata")
68+
}
69+
if len(kvs) == 0 {
70+
break
71+
}
72+
appended, halt, err := appendAdminBucketSummaries(out, kvs, prefix)
73+
if err != nil {
74+
return nil, err
75+
}
76+
out = appended
77+
if halt {
78+
// A key outside the metadata prefix means the
79+
// table-of-contents layout changed mid-scan; returning
80+
// what we have is safer than fabricating a summary
81+
// from an unrelated key.
82+
return finaliseAdminBucketList(out), nil
83+
}
84+
if len(kvs) < adminBucketScanPage {
85+
break
86+
}
87+
start = nextScanCursor(kvs[len(kvs)-1].Key)
88+
if end != nil && bytes.Compare(start, end) > 0 {
89+
break
90+
}
91+
}
92+
return finaliseAdminBucketList(out), nil
93+
}
94+
95+
// appendAdminBucketSummaries projects one ScanAt page into the
96+
// accumulating result slice. Returns the extended slice plus a halt
97+
// flag the caller uses to short-circuit when ScanAt yielded a key
98+
// outside the bucket-meta prefix (a defensive check that should not
99+
// trigger in practice but locks the contract). Splitting this out
100+
// keeps AdminListBuckets under the cyclomatic ceiling without
101+
// hiding the per-row decode + skip logic.
102+
func appendAdminBucketSummaries(out []AdminBucketSummary, kvs []*store.KVPair, prefix []byte) ([]AdminBucketSummary, bool, error) {
103+
for _, kvp := range kvs {
104+
if !bytes.HasPrefix(kvp.Key, prefix) {
105+
return out, true, nil
106+
}
107+
bucket, ok := s3keys.ParseBucketMetaKey(kvp.Key)
108+
if !ok {
109+
continue
110+
}
111+
meta, err := decodeS3BucketMeta(kvp.Value)
112+
if err != nil {
113+
return nil, false, errors.Wrapf(err, "admin list buckets: decode metadata for %q", bucket)
114+
}
115+
if meta == nil {
116+
continue
117+
}
118+
out = append(out, summaryFromBucketMeta(bucket, meta))
119+
}
120+
return out, false, nil
121+
}
122+
123+
// finaliseAdminBucketList sorts the accumulated summaries and
124+
// returns the result. Pulled out because the scan loop has two
125+
// early-exit branches (prefix-mismatch defensive return + the
126+
// natural end-of-prefix exit) and both must guarantee
127+
// lexicographic ordering — one place to enforce it is safer than
128+
// two near-identical sort.Slice calls.
129+
func finaliseAdminBucketList(out []AdminBucketSummary) []AdminBucketSummary {
130+
// ScanAt yields metadata-prefix order, which is already
131+
// lexicographic by escaped name on the ASCII bucket-name
132+
// alphabet. The final sort is defensive against a future
133+
// key-encoding change rather than a correction today.
134+
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
135+
return out
136+
}
137+
138+
// AdminDescribeBucket returns the bucket-level snapshot for name.
139+
// The triple (result, present, error) lets admin callers
140+
// distinguish a genuine "not found" from a storage error without
141+
// sniffing sentinels — when the bucket is missing the function
142+
// returns (nil, false, nil), mirroring AdminDescribeTable's
143+
// contract on the Dynamo side.
144+
//
145+
// Like AdminListBuckets this is a read-only path that bypasses
146+
// SigV4. The HTTP admin handler enforces session + CSRF + role at
147+
// the boundary; the adapter trusts the caller for authentication
148+
// (Section 3.2's exception for read-only paths).
149+
func (s *S3Server) AdminDescribeBucket(ctx context.Context, name string) (*AdminBucketSummary, bool, error) {
150+
readTS := s.readTS()
151+
readPin := s.pinReadTS(readTS)
152+
defer readPin.Release()
153+
meta, exists, err := s.loadBucketMetaAt(ctx, name, readTS)
154+
if err != nil {
155+
return nil, false, errors.Wrapf(err, "admin describe bucket %q", name)
156+
}
157+
if !exists || meta == nil {
158+
return nil, false, nil
159+
}
160+
summary := summaryFromBucketMeta(name, meta)
161+
return &summary, true, nil
162+
}
163+
164+
// summaryFromBucketMeta projects the on-disk metadata into the
165+
// admin DTO. Pulled out so list and describe both produce the same
166+
// shape, including the empty-string defaults for optional fields —
167+
// the SPA depends on these being present even when blank.
168+
func summaryFromBucketMeta(name string, meta *s3BucketMeta) AdminBucketSummary {
169+
return AdminBucketSummary{
170+
Name: name,
171+
ACL: meta.Acl,
172+
CreatedAtHLC: meta.CreatedAtHLC,
173+
Generation: meta.Generation,
174+
Region: meta.Region,
175+
Owner: meta.Owner,
176+
}
177+
}

adapter/s3_admin_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package adapter
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/bootjp/elastickv/store"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// TestS3Server_AdminListBuckets_EmptyReturnsEmptySlice covers the
15+
// "no buckets at all" case so the admin handler can rely on getting
16+
// an empty slice — not nil — and produce a stable `[]` JSON shape.
17+
func TestS3Server_AdminListBuckets_EmptyReturnsEmptySlice(t *testing.T) {
18+
t.Parallel()
19+
20+
st := store.NewMVCCStore()
21+
server := NewS3Server(nil, "", st, newLocalAdapterCoordinator(st), nil)
22+
23+
got, err := server.AdminListBuckets(context.Background())
24+
require.NoError(t, err)
25+
require.NotNil(t, got, "must return non-nil slice for empty state so the admin JSON shape is `[]`")
26+
require.Empty(t, got)
27+
}
28+
29+
// TestS3Server_AdminListBuckets_ReflectsCreatedBuckets confirms the
30+
// SigV4-bypass admin path sees the same buckets a normal SigV4
31+
// CreateBucket flow produced. The two views share loadBucketMetaAt
32+
// + the metadata-prefix scan, so any drift here is an encoding bug
33+
// in summaryFromBucketMeta — exactly the regression the test pins.
34+
func TestS3Server_AdminListBuckets_ReflectsCreatedBuckets(t *testing.T) {
35+
t.Parallel()
36+
37+
st := store.NewMVCCStore()
38+
server := NewS3Server(nil, "", st, newLocalAdapterCoordinator(st), nil)
39+
40+
for _, name := range []string{"alpha", "bravo", "charlie"} {
41+
rec := httptest.NewRecorder()
42+
req := newS3TestRequest(http.MethodPut, "/"+name, nil)
43+
server.handle(rec, req)
44+
require.Equal(t, http.StatusOK, rec.Code, "create %s", name)
45+
}
46+
47+
got, err := server.AdminListBuckets(context.Background())
48+
require.NoError(t, err)
49+
require.Len(t, got, 3)
50+
// ScanAt produces metadata-prefix order (lexicographic by
51+
// escaped name); summaryFromBucketMeta preserves that.
52+
require.Equal(t, "alpha", got[0].Name)
53+
require.Equal(t, "bravo", got[1].Name)
54+
require.Equal(t, "charlie", got[2].Name)
55+
for _, b := range got {
56+
require.Equal(t, s3AclPrivate, b.ACL,
57+
"unspecified ACL must default to private (matches createBucket)")
58+
require.NotZero(t, b.CreatedAtHLC, "creation HLC must be populated")
59+
require.NotZero(t, b.Generation, "generation must be populated")
60+
}
61+
}
62+
63+
// TestS3Server_AdminDescribeBucket_Existing returns the populated
64+
// summary with ACL / region preserved through the bridge, and
65+
// (nil, false, nil) for a missing name. The handler depends on the
66+
// (nil, false, nil) shape to differentiate "not found" from a
67+
// storage failure without sniffing sentinels.
68+
func TestS3Server_AdminDescribeBucket_Existing(t *testing.T) {
69+
t.Parallel()
70+
71+
st := store.NewMVCCStore()
72+
server := NewS3Server(nil, "", st, newLocalAdapterCoordinator(st), nil)
73+
74+
rec := httptest.NewRecorder()
75+
req := newS3TestRequest(http.MethodPut, "/orders", nil)
76+
req.Header.Set("x-amz-acl", s3AclPublicRead)
77+
server.handle(rec, req)
78+
require.Equal(t, http.StatusOK, rec.Code)
79+
80+
got, exists, err := server.AdminDescribeBucket(context.Background(), "orders")
81+
require.NoError(t, err)
82+
require.True(t, exists)
83+
require.NotNil(t, got)
84+
require.Equal(t, "orders", got.Name)
85+
require.Equal(t, s3AclPublicRead, got.ACL,
86+
"explicit x-amz-acl must round-trip through the admin describe path")
87+
require.NotZero(t, got.CreatedAtHLC)
88+
}
89+
90+
func TestS3Server_AdminDescribeBucket_Missing(t *testing.T) {
91+
t.Parallel()
92+
93+
st := store.NewMVCCStore()
94+
server := NewS3Server(nil, "", st, newLocalAdapterCoordinator(st), nil)
95+
96+
got, exists, err := server.AdminDescribeBucket(context.Background(), "no-such-bucket")
97+
require.NoError(t, err)
98+
require.False(t, exists)
99+
require.Nil(t, got)
100+
}
101+
102+
// TestS3Server_AdminListBuckets_PaginatesPastSinglePage pins the
103+
// fix for the truncation bug Codex P1 / Claude Issue 1 / Gemini
104+
// flagged on PR #658: AdminListBuckets must walk the metadata
105+
// prefix until exhausted, not stop at adminBucketScanPage. The
106+
// test exceeds the per-iteration page by 100 buckets (1100 total)
107+
// so a regression that re-introduces a single-call ScanAt would
108+
// silently drop the tail and the assertion fails.
109+
//
110+
// Total bucket count (1100) is small enough to keep the test
111+
// O(seconds) on the in-memory MVCC store. Names are zero-padded to
112+
// 4 digits so lexicographic order matches numeric order — the test
113+
// pins both the count AND the ordering contract.
114+
func TestS3Server_AdminListBuckets_PaginatesPastSinglePage(t *testing.T) {
115+
t.Parallel()
116+
117+
st := store.NewMVCCStore()
118+
server := NewS3Server(nil, "", st, newLocalAdapterCoordinator(st), nil)
119+
120+
const total = adminBucketScanPage + 100
121+
for i := range total {
122+
name := fmt.Sprintf("bucket-%04d", i)
123+
rec := httptest.NewRecorder()
124+
req := newS3TestRequest(http.MethodPut, "/"+name, nil)
125+
server.handle(rec, req)
126+
require.Equal(t, http.StatusOK, rec.Code, "create %s", name)
127+
}
128+
129+
got, err := server.AdminListBuckets(context.Background())
130+
require.NoError(t, err)
131+
require.Len(t, got, total,
132+
"AdminListBuckets must continue past adminBucketScanPage; truncating here is the regression")
133+
require.Equal(t, "bucket-0000", got[0].Name)
134+
require.Equal(t, fmt.Sprintf("bucket-%04d", total-1), got[total-1].Name)
135+
}

internal/admin/buckets_source.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package admin
2+
3+
import (
4+
"context"
5+
"errors"
6+
)
7+
8+
// BucketsSource is the in-process surface the admin S3 handler
9+
// dispatches into. It mirrors TablesSource on the Dynamo side
10+
// (Section 3.2 of the admin design): defining the contract here lets
11+
// the bridge in main_admin.go translate adapter errors into the
12+
// admin-package vocabulary without the adapter package importing
13+
// internal/admin.
14+
//
15+
// All methods are read-only in this slice. Write methods
16+
// (AdminCreateBucket, AdminPutBucketAcl, AdminDeleteBucket) ship in
17+
// the next slice with AdminForward integration so a follower can
18+
// hand them off to the leader transparently.
19+
type BucketsSource interface {
20+
// AdminListBuckets returns every bucket this server knows about,
21+
// in stable lexicographic order. The empty list is a valid
22+
// response — the handler returns `{"buckets":[]}` rather than
23+
// 404 so the SPA can distinguish "no buckets yet" from "S3
24+
// admin not configured" (the latter shape is a 404 from the
25+
// router fallthrough).
26+
AdminListBuckets(ctx context.Context) ([]BucketSummary, error)
27+
// AdminDescribeBucket returns the metadata snapshot for name.
28+
// The triple (result, present, error) lets the handler emit a
29+
// 404 for missing buckets without sniffing sentinels; storage
30+
// failures still surface via the error return.
31+
AdminDescribeBucket(ctx context.Context, name string) (*BucketSummary, bool, error)
32+
}
33+
34+
// BucketSummary is the bucket-level DTO the SPA receives. The JSON
35+
// shape matches the design doc Section 4.1 / web/admin's
36+
// `S3Bucket` interface — bucket_name + acl + created_at — plus
37+
// generation/region/owner for operators inspecting via curl.
38+
//
39+
// CreatedAt is an ISO-8601 string (UTC, second precision). The
40+
// adapter persists it as an HLC; the handler formats. Producing
41+
// the formatted string here rather than in the SPA keeps timezone
42+
// rendering server-side and prevents drift between the two SPA
43+
// pages that surface buckets (S3List + S3Detail).
44+
type BucketSummary struct {
45+
Name string `json:"bucket_name"`
46+
ACL string `json:"acl,omitempty"`
47+
CreatedAt string `json:"created_at,omitempty"`
48+
Generation uint64 `json:"generation,omitempty"`
49+
Region string `json:"region,omitempty"`
50+
Owner string `json:"owner,omitempty"`
51+
}
52+
53+
// ErrBucketsForbidden is returned when the principal lacks the
54+
// role required for the operation. Maps to 403. Kept as its own
55+
// sentinel (rather than reusing ErrTablesForbidden) so a future
56+
// per-resource role model can diverge without breaking either
57+
// handler's match list.
58+
var ErrBucketsForbidden = errors.New("admin buckets: principal lacks required role")
59+
60+
// ErrBucketsNotLeader is returned when the local node is not the
61+
// Raft leader for the S3 group. Read-only methods do NOT return
62+
// this — list / describe are leader-agnostic in this slice. Kept
63+
// here so the next slice's write methods can wire it without
64+
// adding a new sentinel.
65+
var ErrBucketsNotLeader = errors.New("admin buckets: local node is not the raft leader")
66+
67+
// ErrBucketsNotFound is returned when DELETE / DESCRIBE / a
68+
// follow-up read targets a bucket that does not exist. The triple
69+
// (nil, false, nil) is the preferred signal for the read path;
70+
// this sentinel covers the future write paths only.
71+
var ErrBucketsNotFound = errors.New("admin buckets: bucket does not exist")
72+
73+
// ErrBucketsAlreadyExists is returned when CREATE targets a name
74+
// that is already in use. Maps to 409. Reserved for the next slice.
75+
var ErrBucketsAlreadyExists = errors.New("admin buckets: bucket already exists")

0 commit comments

Comments
 (0)