Skip to content

Commit 14ae1e9

Browse files
committed
admin: add CreateTable / DeleteTable write endpoints (P1, leader-only)
POST /admin/api/v1/dynamo/tables and DELETE /admin/api/v1/dynamo/tables/{name}, behind the existing protect chain (BodyLimit -> SessionAuth -> Audit -> CSRF) plus an in-handler RequireWriteRole-equivalent role check. Audit fires on the write path automatically. Adapter (SigV4 bypass per Section 3.2): - AdminCreateTable / AdminDeleteTable take an AdminPrincipal and re-evaluate the role even when a higher layer already enforced it ("認可の真実は常に adapter 側" invariant from the design) - ErrAdminNotLeader / ErrAdminForbidden sentinels signal the leader guard and the role denial respectively; the future AdminForward RPC catches ErrAdminNotLeader to forward to the leader instead of returning 503 - IsAdminTableAlreadyExists / IsAdminTableNotFound / IsAdminValidation + AdminErrorMessage expose the structured error vocabulary so the bridge in main_admin.go can map adapter errors to admin sentinels without importing the package-private dynamoAPIError type Admin handler (internal/admin/dynamo_handler.go): - TablesSource extended with AdminCreateTable / AdminDeleteTable - CreateTableRequest JSON shape per design 4.2 (table_name, partition_key, sort_key, gsi[]); strict decoding rejects unknown fields and the validator sanitises every error message before it reaches the SPA - ErrTablesForbidden / ErrTablesNotLeader / ErrTablesNotFound / ErrTablesAlreadyExists + ValidationError sentinels carry the structured failure modes; writeTablesError maps them to 403, 503 + Retry-After: 1, 404, 409, 400 respectively - Generic source errors are logged in full and surfaced as dynamo_create_failed / dynamo_delete_failed; raw err.Error() is never sent over the wire Bridge (main_admin.go): - dynamoTablesBridge implements the new methods, translating the admin DTO <-> adapter input pair and the error vocabulary in both directions - Two principal types deliberately stay independent so neither package depends on the other; convertAdminPrincipal keeps them in sync Leader-only is interim. Follower-side AdminForward RPC (Section 3.3 acceptance criteria 1-6) ships in a follow-up PR.
1 parent ec64f1c commit 14ae1e9

6 files changed

Lines changed: 1313 additions & 12 deletions

File tree

adapter/dynamodb_admin.go

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ package adapter
22

33
import (
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+
55378
func summaryFromSchema(s *dynamoTableSchema) *AdminTableSummary {
56379
out := &AdminTableSummary{
57380
Name: s.TableName,

0 commit comments

Comments
 (0)