Skip to content

Commit ec64f1c

Browse files
committed
admin: add read-only DynamoDB tables endpoints (P1)
GET /admin/api/v1/dynamo/tables and GET /admin/api/v1/dynamo/tables/{name} land the design doc Section 4.1 read-only paths. Both go through the same protect chain (BodyLimit -> SessionAuth -> Audit -> CSRF) as write endpoints; Audit is a no-op on GET so dashboard polling does not flood the audit log. Adapter side (SigV4 bypass per Section 3.2): - adapter.AdminListTables / AdminDescribeTable internal entrypoints - AdminTableSummary DTO keeps dynamoTableSchema private; admin gets a stable struct that does not drift with the wire format Admin handler (internal/admin/dynamo_handler.go): - TablesSource interface (production wired, tests stub) - limit (default 100, hard max 1000) + opaque base64 next_token pagination matching design Section 4.3 - vanished-cursor fast-forward so a deleted-between-pages name does not stall the SPA - internal errors hidden behind dynamo_list_failed / dynamo_describe_failed codes; raw err.Error() is never leaked to clients Server wiring: - ServerDeps.Tables is optional; nil leaves the dynamo paths off the wire entirely (returns the standard unknown_endpoint 404) - main_admin.go bridges *adapter.DynamoDBServer via dynamoTablesBridge, translating adapter.AdminTableSummary <-> admin.DynamoTableSummary (isomorphic so any field drift breaks the build)
1 parent dbe4725 commit ec64f1c

9 files changed

Lines changed: 1166 additions & 26 deletions

File tree

adapter/dynamodb_admin.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package adapter
2+
3+
import (
4+
"context"
5+
"sort"
6+
)
7+
8+
// AdminTableSummary is the table-level information the admin dashboard
9+
// surfaces for a single Dynamo-compatible table. It deliberately
10+
// projects only the fields the dashboard needs so the package's
11+
// wire-format types (dynamoTableSchema and friends) stay internal.
12+
type AdminTableSummary struct {
13+
Name string
14+
PartitionKey string
15+
SortKey string
16+
Generation uint64
17+
GlobalSecondaryIndexes []AdminGSISummary
18+
}
19+
20+
// AdminGSISummary mirrors AdminTableSummary for a single GSI.
21+
type AdminGSISummary struct {
22+
Name string
23+
PartitionKey string
24+
SortKey string
25+
ProjectionType string
26+
}
27+
28+
// AdminListTables returns every Dynamo-style table this server knows
29+
// about, in the lexicographic order the metadata index produces.
30+
// Intended for the in-process admin listener as the SigV4-free
31+
// counterpart to the listTables HTTP handler; both share the same
32+
// underlying lookup so the two views cannot drift.
33+
func (d *DynamoDBServer) AdminListTables(ctx context.Context) ([]string, error) {
34+
return d.listTableNames(ctx)
35+
}
36+
37+
// AdminDescribeTable returns a schema snapshot for name. The triple
38+
// (result, present, error) lets admin callers distinguish a genuine
39+
// "not found" from a storage error without sniffing sentinels: when
40+
// the table is missing the function returns (nil, false, nil).
41+
func (d *DynamoDBServer) AdminDescribeTable(ctx context.Context, name string) (*AdminTableSummary, bool, error) {
42+
if err := d.ensureLegacyTableMigration(ctx, name); err != nil {
43+
return nil, false, err
44+
}
45+
schema, exists, err := d.loadTableSchema(ctx, name)
46+
if err != nil {
47+
return nil, false, err
48+
}
49+
if !exists {
50+
return nil, false, nil
51+
}
52+
return summaryFromSchema(schema), true, nil
53+
}
54+
55+
func summaryFromSchema(s *dynamoTableSchema) *AdminTableSummary {
56+
out := &AdminTableSummary{
57+
Name: s.TableName,
58+
PartitionKey: s.PrimaryKey.HashKey,
59+
SortKey: s.PrimaryKey.RangeKey,
60+
Generation: s.Generation,
61+
}
62+
if len(s.GlobalSecondaryIndexes) == 0 {
63+
return out
64+
}
65+
names := make([]string, 0, len(s.GlobalSecondaryIndexes))
66+
for n := range s.GlobalSecondaryIndexes {
67+
names = append(names, n)
68+
}
69+
// Sort so the JSON the admin handler emits is deterministic; map
70+
// iteration order would otherwise produce an unstable output that
71+
// breaks both UI diffing and snapshot tests.
72+
sort.Strings(names)
73+
out.GlobalSecondaryIndexes = make([]AdminGSISummary, 0, len(names))
74+
for _, name := range names {
75+
gsi := s.GlobalSecondaryIndexes[name]
76+
out.GlobalSecondaryIndexes = append(out.GlobalSecondaryIndexes, AdminGSISummary{
77+
Name: name,
78+
PartitionKey: gsi.KeySchema.HashKey,
79+
SortKey: gsi.KeySchema.RangeKey,
80+
ProjectionType: gsi.Projection.ProjectionType,
81+
})
82+
}
83+
return out
84+
}

adapter/dynamodb_admin_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package adapter
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/aws/aws-sdk-go-v2/aws"
8+
"github.com/aws/aws-sdk-go-v2/config"
9+
"github.com/aws/aws-sdk-go-v2/credentials"
10+
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
11+
ddbTypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// TestDynamoDB_AdminListTables_Empty exercises the SigV4-bypass admin
16+
// entrypoint on a server that has no Dynamo tables. The expected shape
17+
// is an empty (non-nil) slice so the admin JSON response stays a valid
18+
// array rather than `null`, matching the design doc 4.3 contract.
19+
func TestDynamoDB_AdminListTables_Empty(t *testing.T) {
20+
t.Parallel()
21+
nodes, _, _ := createNode(t, 1)
22+
defer shutdown(nodes)
23+
24+
got, err := nodes[0].dynamoServer.AdminListTables(context.Background())
25+
require.NoError(t, err)
26+
require.Empty(t, got)
27+
}
28+
29+
// TestDynamoDB_AdminListTables_Sorted verifies that the admin entrypoint
30+
// returns table names in lexicographic order, matching the listTables
31+
// HTTP handler so the two admin views (SigV4 and bypass) cannot drift.
32+
func TestDynamoDB_AdminListTables_Sorted(t *testing.T) {
33+
t.Parallel()
34+
nodes, _, _ := createNode(t, 1)
35+
defer shutdown(nodes)
36+
37+
client := newDynamoClient(t, nodes[0].dynamoAddress)
38+
ctx := context.Background()
39+
40+
for _, name := range []string{"zeta", "alpha", "mu"} {
41+
_, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{
42+
TableName: aws.String(name),
43+
BillingMode: ddbTypes.BillingModePayPerRequest,
44+
AttributeDefinitions: []ddbTypes.AttributeDefinition{
45+
{AttributeName: aws.String("pk"), AttributeType: ddbTypes.ScalarAttributeTypeS},
46+
},
47+
KeySchema: []ddbTypes.KeySchemaElement{
48+
{AttributeName: aws.String("pk"), KeyType: ddbTypes.KeyTypeHash},
49+
},
50+
})
51+
require.NoError(t, err)
52+
}
53+
54+
got, err := nodes[0].dynamoServer.AdminListTables(ctx)
55+
require.NoError(t, err)
56+
require.Equal(t, []string{"alpha", "mu", "zeta"}, got)
57+
}
58+
59+
// TestDynamoDB_AdminDescribeTable_Missing checks the (nil, false, nil)
60+
// "not found" contract — admin callers must be able to tell a missing
61+
// table apart from a storage error without sniffing sentinels.
62+
func TestDynamoDB_AdminDescribeTable_Missing(t *testing.T) {
63+
t.Parallel()
64+
nodes, _, _ := createNode(t, 1)
65+
defer shutdown(nodes)
66+
67+
summary, exists, err := nodes[0].dynamoServer.AdminDescribeTable(context.Background(), "absent")
68+
require.NoError(t, err)
69+
require.False(t, exists)
70+
require.Nil(t, summary)
71+
}
72+
73+
// TestDynamoDB_AdminDescribeTable_Composite covers the simple-key happy
74+
// path: a table with hash + range key and no GSIs. The admin summary
75+
// must mirror the schema's primary key fields exactly.
76+
func TestDynamoDB_AdminDescribeTable_Composite(t *testing.T) {
77+
t.Parallel()
78+
nodes, _, _ := createNode(t, 1)
79+
defer shutdown(nodes)
80+
81+
client := newDynamoClient(t, nodes[0].dynamoAddress)
82+
ctx := context.Background()
83+
84+
_, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{
85+
TableName: aws.String("orders"),
86+
BillingMode: ddbTypes.BillingModePayPerRequest,
87+
AttributeDefinitions: []ddbTypes.AttributeDefinition{
88+
{AttributeName: aws.String("customer"), AttributeType: ddbTypes.ScalarAttributeTypeS},
89+
{AttributeName: aws.String("orderID"), AttributeType: ddbTypes.ScalarAttributeTypeS},
90+
},
91+
KeySchema: []ddbTypes.KeySchemaElement{
92+
{AttributeName: aws.String("customer"), KeyType: ddbTypes.KeyTypeHash},
93+
{AttributeName: aws.String("orderID"), KeyType: ddbTypes.KeyTypeRange},
94+
},
95+
})
96+
require.NoError(t, err)
97+
98+
summary, exists, err := nodes[0].dynamoServer.AdminDescribeTable(ctx, "orders")
99+
require.NoError(t, err)
100+
require.True(t, exists)
101+
require.NotNil(t, summary)
102+
require.Equal(t, "orders", summary.Name)
103+
require.Equal(t, "customer", summary.PartitionKey)
104+
require.Equal(t, "orderID", summary.SortKey)
105+
require.NotZero(t, summary.Generation)
106+
require.Empty(t, summary.GlobalSecondaryIndexes)
107+
}
108+
109+
// TestDynamoDB_AdminDescribeTable_GSI_SortedDeterministic exercises the
110+
// GSI projection path. Two indexes are added in deliberately reversed
111+
// alphabetical order to confirm summaryFromSchema's Sort.Strings call —
112+
// without it, map iteration order would produce a flaky output.
113+
func TestDynamoDB_AdminDescribeTable_GSI_SortedDeterministic(t *testing.T) {
114+
t.Parallel()
115+
nodes, _, _ := createNode(t, 1)
116+
defer shutdown(nodes)
117+
118+
client := newDynamoClient(t, nodes[0].dynamoAddress)
119+
ctx := context.Background()
120+
121+
_, err := client.CreateTable(ctx, &dynamodb.CreateTableInput{
122+
TableName: aws.String("threads"),
123+
BillingMode: ddbTypes.BillingModePayPerRequest,
124+
AttributeDefinitions: []ddbTypes.AttributeDefinition{
125+
{AttributeName: aws.String("threadId"), AttributeType: ddbTypes.ScalarAttributeTypeS},
126+
{AttributeName: aws.String("status"), AttributeType: ddbTypes.ScalarAttributeTypeS},
127+
{AttributeName: aws.String("owner"), AttributeType: ddbTypes.ScalarAttributeTypeS},
128+
{AttributeName: aws.String("createdAt"), AttributeType: ddbTypes.ScalarAttributeTypeS},
129+
},
130+
KeySchema: []ddbTypes.KeySchemaElement{
131+
{AttributeName: aws.String("threadId"), KeyType: ddbTypes.KeyTypeHash},
132+
},
133+
GlobalSecondaryIndexes: []ddbTypes.GlobalSecondaryIndex{
134+
{
135+
IndexName: aws.String("zStatusIndex"),
136+
KeySchema: []ddbTypes.KeySchemaElement{
137+
{AttributeName: aws.String("status"), KeyType: ddbTypes.KeyTypeHash},
138+
{AttributeName: aws.String("createdAt"), KeyType: ddbTypes.KeyTypeRange},
139+
},
140+
Projection: &ddbTypes.Projection{ProjectionType: ddbTypes.ProjectionTypeAll},
141+
},
142+
{
143+
IndexName: aws.String("aOwnerIndex"),
144+
KeySchema: []ddbTypes.KeySchemaElement{
145+
{AttributeName: aws.String("owner"), KeyType: ddbTypes.KeyTypeHash},
146+
},
147+
Projection: &ddbTypes.Projection{ProjectionType: ddbTypes.ProjectionTypeKeysOnly},
148+
},
149+
},
150+
})
151+
require.NoError(t, err)
152+
153+
summary, exists, err := nodes[0].dynamoServer.AdminDescribeTable(ctx, "threads")
154+
require.NoError(t, err)
155+
require.True(t, exists)
156+
require.NotNil(t, summary)
157+
require.Equal(t, "threadId", summary.PartitionKey)
158+
require.Empty(t, summary.SortKey)
159+
160+
require.Len(t, summary.GlobalSecondaryIndexes, 2)
161+
// Names sorted lexicographically: "aOwnerIndex" < "zStatusIndex".
162+
require.Equal(t, "aOwnerIndex", summary.GlobalSecondaryIndexes[0].Name)
163+
require.Equal(t, "owner", summary.GlobalSecondaryIndexes[0].PartitionKey)
164+
require.Empty(t, summary.GlobalSecondaryIndexes[0].SortKey)
165+
require.Equal(t, string(ddbTypes.ProjectionTypeKeysOnly), summary.GlobalSecondaryIndexes[0].ProjectionType)
166+
167+
require.Equal(t, "zStatusIndex", summary.GlobalSecondaryIndexes[1].Name)
168+
require.Equal(t, "status", summary.GlobalSecondaryIndexes[1].PartitionKey)
169+
require.Equal(t, "createdAt", summary.GlobalSecondaryIndexes[1].SortKey)
170+
require.Equal(t, string(ddbTypes.ProjectionTypeAll), summary.GlobalSecondaryIndexes[1].ProjectionType)
171+
}
172+
173+
func newDynamoClient(t *testing.T, address string) *dynamodb.Client {
174+
t.Helper()
175+
cfg, err := config.LoadDefaultConfig(context.Background(),
176+
config.WithRegion("us-west-2"),
177+
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("dummy", "dummy", "")),
178+
)
179+
require.NoError(t, err)
180+
return dynamodb.NewFromConfig(cfg, func(o *dynamodb.Options) {
181+
o.BaseEndpoint = aws.String("http://" + address)
182+
})
183+
}

0 commit comments

Comments
 (0)