Skip to content

Commit 5ffa980

Browse files
authored
feat: implement cursor-based pagination (#96)
* feat: implement cursor-based pagination and add migration rollback command This release introduces cursor-based pagination across all list operations to improve performance and consistency, along with new administrative commands for database and secret management. Key changes: - Refactor all list operations (Audit Logs, Clients, Secrets, Tokenization Keys, and Transit Keys) from offset-based to cursor-based pagination. - Add `migrate-down` CLI command to rollback database migrations. - Add `purge-secrets` CLI command to permanently delete soft-deleted secrets with dry-run and multiple output formats support. - Add configurable HTTP server read, write, and idle timeouts. - Implement KMS connectivity validation at server startup. - Update documentation and OpenAPI specification for new pagination and commands. - Correct `rotate-master-key` CLI flags and documentation. - Bump version to v0.25.0 and update CHANGELOG.md. * fix tests
1 parent d5751e7 commit 5ffa980

78 files changed

Lines changed: 2654 additions & 3025 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.25.0] - 2026-03-03
9+
10+
### Added
11+
- Added secret purge command (`purge-secrets`) with dry-run and formatting support.
12+
- Added configurable server read/write/idle timeouts for better resource management and security.
13+
- Added KMS connectivity validation at server startup.
14+
- Added security scanning tools to CI and development workflow.
15+
- Updated documentation and CI configuration to enforce coverage and code quality standards.
16+
17+
### Fixed
18+
- Corrected `rotate-master-key` CLI flags (`kms-provider` and `kms-key-uri`) and documentation to ensure consistency and completeness.
19+
- Fixed integration tests setup by separating them out from unit tests.
20+
821
## [0.24.0] - 2026-03-03
922

1023
### Changed
@@ -415,6 +428,8 @@ If you are using `sslmode=disable` (PostgreSQL) or `tls=false` (MySQL) in produc
415428
- Security model documentation
416429
- Architecture documentation
417430

431+
[0.25.0]: https://github.com/allisson/secrets/compare/v0.24.0...v0.25.0
432+
[0.24.0]: https://github.com/allisson/secrets/compare/v0.23.0...v0.24.0
418433
[0.23.0]: https://github.com/allisson/secrets/compare/v0.22.1...v0.23.0
419434
[0.22.1]: https://github.com/allisson/secrets/compare/v0.22.0...v0.22.1
420435
[0.22.0]: https://github.com/allisson/secrets/compare/v0.21.0...v0.22.0

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ migrate-up: ## Run database migrations up
121121
@$(BINARY) migrate
122122

123123
migrate-down: ## Run database migrations down
124-
@echo "Rollback migrations not implemented in binary. Use golang-migrate CLI directly."
124+
@echo "Rolling back migrations..."
125+
@$(BINARY) migrate-down --steps=1
125126

126127
# Docker
127128
docker-build: ## Build Docker image with version injection

cmd/app/commands/migrations.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,32 @@ func RunMigrations(logger *slog.Logger, dbDriver, dbConnectionString string) err
3838
logger.Info("migrations completed successfully")
3939
return nil
4040
}
41+
42+
// RunMigrationsDown rolls back database migrations based on the configured driver.
43+
// Determines migration path from DBDriver (postgresql or mysql) and rolls back the specified
44+
// number of migrations. Returns nil if no migrations to rollback. Logs migration progress and success.
45+
func RunMigrationsDown(logger *slog.Logger, dbDriver, dbConnectionString string, steps int) error {
46+
logger.Info("rolling back database migrations",
47+
slog.String("driver", dbDriver),
48+
slog.Int("steps", steps),
49+
)
50+
51+
// Determine migration path based on driver
52+
migrationsPath := "file://migrations/postgresql"
53+
if dbDriver == "mysql" {
54+
migrationsPath = "file://migrations/mysql"
55+
}
56+
57+
m, err := migrate.New(migrationsPath, dbConnectionString)
58+
if err != nil {
59+
return fmt.Errorf("failed to create migrate instance: %w", err)
60+
}
61+
defer CloseMigrate(m, logger)
62+
63+
if err := m.Steps(-steps); err != nil && !errors.Is(err, migrate.ErrNoChange) {
64+
return fmt.Errorf("failed to rollback migrations: %w", err)
65+
}
66+
67+
logger.Info("migrations rolled back successfully")
68+
return nil
69+
}

cmd/app/commands/migrations_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,27 @@ func TestRunMigrations(t *testing.T) {
2323
require.Contains(t, err.Error(), "failed to create migrate instance")
2424
})
2525
}
26+
27+
func TestRunMigrationsDown(t *testing.T) {
28+
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
29+
30+
t.Run("invalid-driver", func(t *testing.T) {
31+
err := RunMigrationsDown(logger, "invalid", "postgres://localhost", 1)
32+
require.Error(t, err)
33+
require.Contains(t, err.Error(), "failed to create migrate instance")
34+
})
35+
36+
t.Run("invalid-connection-string", func(t *testing.T) {
37+
err := RunMigrationsDown(logger, "postgres", "invalid-connection-string", 1)
38+
require.Error(t, err)
39+
require.Contains(t, err.Error(), "failed to create migrate instance")
40+
})
41+
42+
t.Run("zero-steps", func(t *testing.T) {
43+
// Zero steps should still attempt to create the migrate instance and return ErrNoChange
44+
err := RunMigrationsDown(logger, "postgres", "postgres://localhost/testdb", 0)
45+
require.Error(t, err)
46+
// Will fail at migrate instance creation since we don't have a real DB, which is expected
47+
require.Contains(t, err.Error(), "failed to create migrate instance")
48+
})
49+
}

cmd/app/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212

1313
// Build-time version information (injected via ldflags during build).
1414
var (
15-
version = "v0.24.0" // Semantic version with "v" prefix (e.g., "v0.12.0")
15+
version = "v0.25.0" // Semantic version with "v" prefix (e.g., "v0.12.0")
1616
buildDate = "unknown" // ISO 8601 build timestamp
1717
commitSHA = "unknown" // Git commit SHA
1818
)

cmd/app/system_commands.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,32 @@ func getSystemCommands(version string) []*cli.Command {
3737
)
3838
},
3939
},
40+
{
41+
Name: "migrate-down",
42+
Usage: "Rollback database migrations",
43+
Flags: []cli.Flag{
44+
&cli.IntFlag{
45+
Name: "steps",
46+
Aliases: []string{"n"},
47+
Value: 1,
48+
Usage: "Number of migrations to rollback",
49+
},
50+
},
51+
Action: func(ctx context.Context, cmd *cli.Command) error {
52+
return commands.ExecuteWithContainer(
53+
ctx,
54+
func(ctx context.Context, container *app.Container) error {
55+
cfg := container.Config()
56+
return commands.RunMigrationsDown(
57+
container.Logger(),
58+
cfg.DBDriver,
59+
cfg.DBConnectionString,
60+
int(cmd.Int("steps")),
61+
)
62+
},
63+
)
64+
},
65+
},
4066
{
4167
Name: "clean-audit-logs",
4268
Usage: "Delete audit logs older than specified days",

docs/cli-commands.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,43 @@ Docker:
5050
docker run --rm --network secrets-net --env-file .env allisson/secrets migrate
5151
```
5252

53+
### `migrate-down`
54+
55+
Rolls back database migrations. This command should **only be used for emergency rollbacks**, not for regular operations.
56+
57+
Flags:
58+
59+
- `--steps`, `-n` (default `1`): number of migrations to rollback
60+
61+
Local:
62+
63+
```bash
64+
# Rollback the last migration
65+
./bin/app migrate-down
66+
67+
# Rollback the last 3 migrations
68+
./bin/app migrate-down --steps 3
69+
```
70+
71+
Docker:
72+
73+
```bash
74+
docker run --rm --network secrets-net --env-file .env allisson/secrets migrate-down --steps 1
75+
```
76+
77+
**Important warnings:**
78+
79+
- Migration rollbacks are **potentially destructive** operations that may result in data loss
80+
- Always backup your database before running rollback operations
81+
- Only use this command for emergency rollbacks (e.g., after a failed migration)
82+
- For production systems, test rollback procedures in a staging environment first
83+
- Consider forward-only migrations instead of rollbacks when possible
84+
85+
Requirements:
86+
87+
- Database must be reachable
88+
- Down migration SQL files must exist for the migrations being rolled back
89+
5390
## Key Management
5491

5592
### `create-master-key`

docs/engines/secrets.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,45 @@ Example response (`200 OK`):
7979

8080
- **Endpoint**: `GET /v1/secrets`
8181
- **Capability**: `read`
82-
- **Query Params**: `offset` (default 0), `limit` (default 50)
82+
- **Query Params**:
83+
- `after_path` (optional) - Cursor for pagination. Omit for first page.
84+
- `limit` (default 50, max 1000) - Number of items per page.
8385
- **Success**: `200 OK` (Does not return secret values)
8486

8587
```bash
86-
curl "http://localhost:8080/v1/secrets?offset=0&limit=50"
88+
# First page
89+
curl "http://localhost:8080/v1/secrets?limit=50" \
8790
-H "Authorization: Bearer <token>"
91+
92+
# Subsequent pages (use next_cursor from previous response)
93+
curl "http://localhost:8080/v1/secrets?after_path=app/prod/db&limit=50" \
94+
-H "Authorization: Bearer <token>"
95+
```
96+
97+
Example response (`200 OK`):
98+
99+
```json
100+
{
101+
"data": [
102+
{
103+
"id": "0194f4a5-73fe-7a7d-a3a0-6fbe9b5ef8f3",
104+
"path": "app/prod/database-password",
105+
"version": 3,
106+
"created_at": "2026-02-27T18:22:00Z"
107+
},
108+
{
109+
"id": "0194f4b2-91ab-7c3d-b5e1-8adc2f6ea4c9",
110+
"path": "app/prod/redis-password",
111+
"version": 1,
112+
"created_at": "2026-02-27T19:15:00Z"
113+
}
114+
],
115+
"next_cursor": "app/prod/redis-password"
116+
}
88117
```
89118

119+
**Note**: The `next_cursor` field is only present when there are more pages available. When it's absent, you've reached the last page.
120+
90121
### Delete Secret
91122

92123
- **Endpoint**: `DELETE /v1/secrets/*path`

docs/engines/tokenization.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,60 @@ Example response (`200 OK`):
9898

9999
### List and Delete Keys
100100

101-
- `GET /v1/tokenization/keys` (Capability: `read`)
102-
- `DELETE /v1/tokenization/keys/:id` (Capability: `delete`)
101+
#### List Tokenization Keys
102+
103+
- **Endpoint**: `GET /v1/tokenization/keys`
104+
- **Capability**: `read`
105+
- **Query Params**:
106+
- `after_name` (optional) - Cursor for pagination. Omit for first page.
107+
- `limit` (default 50, max 1000) - Number of items per page.
108+
- **Success**: `200 OK`
109+
110+
```bash
111+
# First page
112+
curl "http://localhost:8080/v1/tokenization/keys?limit=50" \
113+
-H "Authorization: Bearer <token>"
114+
115+
# Subsequent pages (use next_cursor from previous response)
116+
curl "http://localhost:8080/v1/tokenization/keys?after_name=payment-tokens&limit=50" \
117+
-H "Authorization: Bearer <token>"
118+
```
119+
120+
Example response (`200 OK`):
121+
122+
```json
123+
{
124+
"data": [
125+
{
126+
"id": "0194f4c1-82de-7f9a-c2b3-9def1a7bc5d8",
127+
"name": "customer-ids",
128+
"algorithm": "uuid_v7",
129+
"is_deterministic": true,
130+
"version": 2,
131+
"created_at": "2026-02-27T20:10:00Z",
132+
"updated_at": "2026-02-28T10:30:00Z"
133+
},
134+
{
135+
"id": "0194f4d3-a5bc-7e2f-d8a1-4bef2c9ad7e1",
136+
"name": "payment-tokens",
137+
"algorithm": "luhn_preserving",
138+
"is_deterministic": false,
139+
"version": 1,
140+
"created_at": "2026-02-27T21:45:00Z",
141+
"updated_at": "2026-02-27T21:45:00Z"
142+
}
143+
],
144+
"next_cursor": "payment-tokens"
145+
}
146+
```
147+
148+
**Note**: The `next_cursor` field is only present when there are more pages available.
149+
150+
#### Delete Tokenization Key
151+
152+
- **Endpoint**: `DELETE /v1/tokenization/keys/:name`
153+
- **Capability**: `delete`
154+
- **Success**: `204 No Content`
103155

104156
## Deterministic Tokenization
105157

docs/engines/transit.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,58 @@ Example decrypt response (`200 OK`):
9898

9999
### List and Delete Keys
100100

101-
- `GET /v1/transit/keys` (Capability: `read`)
102-
- `DELETE /v1/transit/keys/:id` (Capability: `delete`)
101+
#### List Transit Keys
102+
103+
- **Endpoint**: `GET /v1/transit/keys`
104+
- **Capability**: `read`
105+
- **Query Params**:
106+
- `after_name` (optional) - Cursor for pagination. Omit for first page.
107+
- `limit` (default 50, max 1000) - Number of items per page.
108+
- **Success**: `200 OK`
109+
110+
```bash
111+
# First page
112+
curl "http://localhost:8080/v1/transit/keys?limit=50" \
113+
-H "Authorization: Bearer <token>"
114+
115+
# Subsequent pages (use next_cursor from previous response)
116+
curl "http://localhost:8080/v1/transit/keys?after_name=main-encryption&limit=50" \
117+
-H "Authorization: Bearer <token>"
118+
```
119+
120+
Example response (`200 OK`):
121+
122+
```json
123+
{
124+
"data": [
125+
{
126+
"id": "0194f4e1-c3ab-7d8e-a9f2-5cde3b8fa6c1",
127+
"name": "app-encryption",
128+
"algorithm": "aes256-gcm96",
129+
"version": 3,
130+
"created_at": "2026-02-27T22:00:00Z",
131+
"updated_at": "2026-02-28T12:00:00Z"
132+
},
133+
{
134+
"id": "0194f4f2-d5bc-7a1f-b8c3-6def4c9eb7d2",
135+
"name": "main-encryption",
136+
"algorithm": "chacha20-poly1305",
137+
"version": 1,
138+
"created_at": "2026-02-27T23:15:00Z",
139+
"updated_at": "2026-02-27T23:15:00Z"
140+
}
141+
],
142+
"next_cursor": "main-encryption"
143+
}
144+
```
145+
146+
**Note**: The `next_cursor` field is only present when there are more pages available.
147+
148+
#### Delete Transit Key
149+
150+
- **Endpoint**: `DELETE /v1/transit/keys/:name`
151+
- **Capability**: `delete`
152+
- **Success**: `204 No Content`
103153

104154
## Relevant CLI Commands
105155

0 commit comments

Comments
 (0)