@@ -2,7 +2,11 @@ package adapter
22
33import (
44 "context"
5+ "net/http"
56 "sort"
7+ "strings"
8+
9+ "github.com/cockroachdb/errors"
610)
711
812// AdminTableSummary is the table-level information the admin dashboard
@@ -52,6 +56,325 @@ func (d *DynamoDBServer) AdminDescribeTable(ctx context.Context, name string) (*
5256 return summaryFromSchema (schema ), true , nil
5357}
5458
59+ // AdminRole is the authorization tier the adapter checks against on
60+ // every admin write entrypoint. The constants intentionally mirror
61+ // internal/admin.Role string values so the wire / persisted role
62+ // vocabulary stays aligned across packages, but we keep a separate
63+ // type here so the adapter has zero dependency on internal/admin.
64+ type AdminRole string
65+
66+ const (
67+ // AdminRoleReadOnly may issue list / describe but not create or delete.
68+ AdminRoleReadOnly AdminRole = "read_only"
69+ // AdminRoleFull may issue every admin operation.
70+ AdminRoleFull AdminRole = "full"
71+ )
72+
73+ // canWrite reports whether the role authorises state-mutating
74+ // operations. Kept as a method (rather than an inline check) so any
75+ // future "delete-only" tier reads consistently across the package.
76+ func (r AdminRole ) canWrite () bool { return r == AdminRoleFull }
77+
78+ // AdminPrincipal is the authentication context every admin write
79+ // entrypoint takes. The adapter re-evaluates authorisation against
80+ // this principal *itself* — it does not trust the caller to have
81+ // already enforced the role. That is the design's "認可の真実は常に
82+ // adapter 側" invariant (Section 3.2): if a follower forwards a
83+ // pre-authenticated request via the future AdminForward RPC, the
84+ // leader must still verify before acting.
85+ type AdminPrincipal struct {
86+ AccessKey string
87+ Role AdminRole
88+ }
89+
90+ // ErrAdminNotLeader is returned by every write entrypoint when this
91+ // node is not the verified Raft leader. The admin HTTP handler
92+ // translates this to 503 + Retry-After: 1 today; the future
93+ // AdminForward RPC catches it as the trigger to forward to the
94+ // leader instead.
95+ var ErrAdminNotLeader = errors .New ("dynamodb admin: this node is not the raft leader" )
96+
97+ // ErrAdminForbidden is returned when the principal lacks the role
98+ // required for the operation. Admin handlers translate this to 403
99+ // "forbidden" without leaking which field of the principal failed
100+ // the check.
101+ var ErrAdminForbidden = errors .New ("dynamodb admin: principal lacks required role" )
102+
103+ // IsAdminTableAlreadyExists reports whether err is the adapter's
104+ // "table already exists" failure (ResourceInUseException). The
105+ // bridge in main_admin.go uses this to map the adapter's internal
106+ // error vocabulary onto admin's HTTP-facing sentinels without
107+ // importing the package-private dynamoAPIError type.
108+ func IsAdminTableAlreadyExists (err error ) bool {
109+ return adminAPIErrorTypeIs (err , dynamoErrResourceInUse )
110+ }
111+
112+ // IsAdminTableNotFound is the ResourceNotFoundException counterpart
113+ // for AdminDeleteTable / AdminDescribeTable mapped through the
114+ // adapter's structured error chain.
115+ func IsAdminTableNotFound (err error ) bool {
116+ return adminAPIErrorTypeIs (err , dynamoErrResourceNotFound )
117+ }
118+
119+ // IsAdminValidation reports whether err is a validation failure
120+ // the adapter signalled via ValidationException. Admin handlers map
121+ // this to 400 + a sanitised message.
122+ func IsAdminValidation (err error ) bool {
123+ return adminAPIErrorTypeIs (err , dynamoErrValidation )
124+ }
125+
126+ // AdminErrorMessage extracts the human-readable message from a
127+ // dynamoAPIError for surfacing back to the SPA. Returns "" when err
128+ // is not a structured adapter error so callers fall back to a
129+ // generic message instead of leaking arbitrary err.Error() output.
130+ func AdminErrorMessage (err error ) string {
131+ var apiErr * dynamoAPIError
132+ if errors .As (err , & apiErr ) && apiErr != nil {
133+ return apiErr .message
134+ }
135+ return ""
136+ }
137+
138+ func adminAPIErrorTypeIs (err error , want string ) bool {
139+ var apiErr * dynamoAPIError
140+ if ! errors .As (err , & apiErr ) || apiErr == nil {
141+ return false
142+ }
143+ return apiErr .errorType == want
144+ }
145+
146+ // AdminAttribute names a single primary-key or GSI key column. Type
147+ // must be one of "S", "N", "B" — DynamoDB does not allow boolean or
148+ // list keys and the adapter's existing schema validation enforces
149+ // the same restriction at the next layer.
150+ type AdminAttribute struct {
151+ Name string
152+ Type string
153+ }
154+
155+ // AdminCreateGSI describes one global secondary index in an admin
156+ // CreateTable request. SortKey is optional (hash-only GSI). When
157+ // ProjectionType is "INCLUDE", NonKeyAttributes lists the projected
158+ // attribute names; otherwise NonKeyAttributes is ignored.
159+ type AdminCreateGSI struct {
160+ Name string
161+ PartitionKey AdminAttribute
162+ SortKey * AdminAttribute
163+ ProjectionType string
164+ NonKeyAttributes []string
165+ }
166+
167+ // AdminCreateTableInput is the admin-facing CreateTable shape. The
168+ // HTTP handler maps the design 4.2 JSON body into this struct, then
169+ // AdminCreateTable converts it to the adapter's internal
170+ // createTableInput. We do not pass the SigV4-flavoured wire struct
171+ // directly because that struct's field names track AWS exactly and
172+ // would be awkward for the admin SPA to author.
173+ type AdminCreateTableInput struct {
174+ TableName string
175+ PartitionKey AdminAttribute
176+ SortKey * AdminAttribute
177+ GSI []AdminCreateGSI
178+ }
179+
180+ // AdminCreateTable creates a Dynamo-compatible table on the local
181+ // node, after re-validating the principal's role and confirming this
182+ // node is the verified Raft leader. The returned summary mirrors the
183+ // shape of AdminDescribeTable on the same name so the SPA can show
184+ // the freshly-created table without an extra describe round-trip.
185+ //
186+ // Errors:
187+ // - ErrAdminForbidden when the principal cannot write.
188+ // - ErrAdminNotLeader when the node is a follower.
189+ // - The adapter's standard dynamoAPIError chain for validation /
190+ // storage failures, preserved unmodified so the HTTP handler can
191+ // map the inner code (ValidationException, ResourceInUseException,
192+ // etc.) to the appropriate status without re-classifying.
193+ func (d * DynamoDBServer ) AdminCreateTable (ctx context.Context , principal AdminPrincipal , in AdminCreateTableInput ) (* AdminTableSummary , error ) {
194+ if ! principal .Role .canWrite () {
195+ return nil , ErrAdminForbidden
196+ }
197+ if ! isVerifiedDynamoLeader (d .coordinator ) {
198+ return nil , ErrAdminNotLeader
199+ }
200+ legacy , err := buildLegacyCreateTableInput (in )
201+ if err != nil {
202+ return nil , err
203+ }
204+ if strings .TrimSpace (legacy .TableName ) == "" {
205+ return nil , newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , "missing table name" )
206+ }
207+ unlock := d .lockTableOperations ([]string {legacy .TableName })
208+ defer unlock ()
209+ schema , err := buildCreateTableSchema (legacy )
210+ if err != nil {
211+ return nil , newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , err .Error ())
212+ }
213+ if err := d .createTableWithRetry (ctx , legacy .TableName , schema ); err != nil {
214+ return nil , err
215+ }
216+ d .observeTables (ctx , schema .TableName )
217+ // Reload after commit so the returned summary carries the
218+ // generation that createTableWithRetry actually persisted —
219+ // `schema` going in had generation 0 because the next number is
220+ // computed inside the retry loop. Reading it back also matches
221+ // the SPA's mental model: the response shape is identical to a
222+ // follow-up AdminDescribeTable call.
223+ stored , exists , err := d .loadTableSchema (ctx , legacy .TableName )
224+ if err != nil {
225+ return nil , err
226+ }
227+ if ! exists {
228+ // Should be unreachable: createTableWithRetry succeeded above
229+ // under the table lock, so a missing schema here means the
230+ // metadata index is corrupt or another writer raced through
231+ // the lock — both of which are server-internal failures.
232+ return nil , newDynamoAPIError (http .StatusInternalServerError , dynamoErrInternal , "table missing immediately after create" )
233+ }
234+ return summaryFromSchema (stored ), nil
235+ }
236+
237+ // AdminDeleteTable is the SigV4-bypass counterpart to deleteTable.
238+ // Returns the same sentinel errors as AdminCreateTable plus the
239+ // adapter's standard dynamoErrResourceNotFound when the table is
240+ // absent — admin handlers should map that to 404 rather than 500.
241+ func (d * DynamoDBServer ) AdminDeleteTable (ctx context.Context , principal AdminPrincipal , name string ) error {
242+ if ! principal .Role .canWrite () {
243+ return ErrAdminForbidden
244+ }
245+ if ! isVerifiedDynamoLeader (d .coordinator ) {
246+ return ErrAdminNotLeader
247+ }
248+ if strings .TrimSpace (name ) == "" {
249+ return newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , "missing table name" )
250+ }
251+ unlock := d .lockTableOperations ([]string {name })
252+ defer unlock ()
253+ return d .deleteTableWithRetry (ctx , name )
254+ }
255+
256+ // buildLegacyCreateTableInput maps the admin-facing struct to the
257+ // adapter's existing wire-format struct so the rest of the schema
258+ // pipeline (buildCreateTableSchema → dispatch) can be reused as-is.
259+ // Doing the translation here — rather than refactoring the wire
260+ // types — keeps SigV4 path bit-exact and limits the blast radius of
261+ // the admin feature.
262+ func buildLegacyCreateTableInput (in AdminCreateTableInput ) (createTableInput , error ) {
263+ if strings .TrimSpace (in .TableName ) == "" {
264+ return createTableInput {}, newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , "missing table name" )
265+ }
266+ if strings .TrimSpace (in .PartitionKey .Name ) == "" {
267+ return createTableInput {}, newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , "missing partition key name" )
268+ }
269+ collector := newAttrCollector ()
270+ out := createTableInput {TableName : in .TableName }
271+ if err := appendKeySchema (& out , in , collector .add ); err != nil {
272+ return createTableInput {}, err
273+ }
274+ for _ , gsi := range in .GSI {
275+ legacy , err := buildLegacyGSI (gsi , collector .add )
276+ if err != nil {
277+ return createTableInput {}, err
278+ }
279+ out .GlobalSecondaryIndexes = append (out .GlobalSecondaryIndexes , legacy )
280+ }
281+ out .AttributeDefinitions = collector .sorted ()
282+ return out , nil
283+ }
284+
285+ // attrCollector merges every attribute referenced by primary key
286+ // and GSIs, rejecting conflicting type declarations for the same
287+ // name. Pulling the bookkeeping out of the build function lets us
288+ // share it with appendKeySchema / buildLegacyGSI without exposing a
289+ // raw map to callers.
290+ type attrCollector struct { set map [string ]string }
291+
292+ func newAttrCollector () * attrCollector { return & attrCollector {set : map [string ]string {}} }
293+
294+ func (c * attrCollector ) add (a AdminAttribute ) error {
295+ if existing , ok := c .set [a .Name ]; ok && existing != a .Type {
296+ return newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation ,
297+ "conflicting attribute type for " + a .Name )
298+ }
299+ c .set [a .Name ] = a .Type
300+ return nil
301+ }
302+
303+ // sorted emits the merged attribute definitions in lexicographic
304+ // order so makeCreateTableRequest produces a byte-identical
305+ // OperationGroup for inputs that differ only in map iteration
306+ // order. Tests that assert against an already-created table can
307+ // then compare schemas without pre-sorting on their side.
308+ func (c * attrCollector ) sorted () []createTableAttributeDefinition {
309+ defs := make ([]createTableAttributeDefinition , 0 , len (c .set ))
310+ for name , typ := range c .set {
311+ defs = append (defs , createTableAttributeDefinition {
312+ AttributeName : name ,
313+ AttributeType : typ ,
314+ })
315+ }
316+ sort .Slice (defs , func (i , j int ) bool {
317+ return defs [i ].AttributeName < defs [j ].AttributeName
318+ })
319+ return defs
320+ }
321+
322+ // appendKeySchema writes the primary key (HASH + optional RANGE)
323+ // into out.KeySchema and registers the same attributes with addAttr
324+ // so AttributeDefinitions stays consistent with the key schema.
325+ func appendKeySchema (out * createTableInput , in AdminCreateTableInput , addAttr func (AdminAttribute ) error ) error {
326+ if err := addAttr (in .PartitionKey ); err != nil {
327+ return err
328+ }
329+ out .KeySchema = append (out .KeySchema , createTableKeySchemaElement {
330+ AttributeName : in .PartitionKey .Name ,
331+ KeyType : "HASH" ,
332+ })
333+ if in .SortKey == nil {
334+ return nil
335+ }
336+ if err := addAttr (* in .SortKey ); err != nil {
337+ return err
338+ }
339+ out .KeySchema = append (out .KeySchema , createTableKeySchemaElement {
340+ AttributeName : in .SortKey .Name ,
341+ KeyType : "RANGE" ,
342+ })
343+ return nil
344+ }
345+
346+ func buildLegacyGSI (gsi AdminCreateGSI , addAttr func (AdminAttribute ) error ) (createTableGSI , error ) {
347+ if strings .TrimSpace (gsi .Name ) == "" {
348+ return createTableGSI {}, newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , "missing GSI name" )
349+ }
350+ if strings .TrimSpace (gsi .PartitionKey .Name ) == "" {
351+ return createTableGSI {}, newDynamoAPIError (http .StatusBadRequest , dynamoErrValidation , "missing GSI partition key name" )
352+ }
353+ if err := addAttr (gsi .PartitionKey ); err != nil {
354+ return createTableGSI {}, err
355+ }
356+ out := createTableGSI {
357+ IndexName : gsi .Name ,
358+ KeySchema : []createTableKeySchemaElement {
359+ {AttributeName : gsi .PartitionKey .Name , KeyType : "HASH" },
360+ },
361+ Projection : createTableProjection {ProjectionType : gsi .ProjectionType },
362+ }
363+ if gsi .SortKey != nil {
364+ if err := addAttr (* gsi .SortKey ); err != nil {
365+ return createTableGSI {}, err
366+ }
367+ out .KeySchema = append (out .KeySchema , createTableKeySchemaElement {
368+ AttributeName : gsi .SortKey .Name ,
369+ KeyType : "RANGE" ,
370+ })
371+ }
372+ if strings .EqualFold (gsi .ProjectionType , "INCLUDE" ) {
373+ out .Projection .NonKeyAttributes = append ([]string (nil ), gsi .NonKeyAttributes ... )
374+ }
375+ return out , nil
376+ }
377+
55378func summaryFromSchema (s * dynamoTableSchema ) * AdminTableSummary {
56379 out := & AdminTableSummary {
57380 Name : s .TableName ,
0 commit comments