feat: Add API key authentication with auto-migrations
Implements API key authentication for all rdev endpoints:
## Database (internal/db)
- Auto-migrating postgres connection
- Embedded SQL migrations via go:embed
- api_keys table with scopes, expiration, project restrictions
## Auth Package (internal/auth)
- Key generation: rdev_sk_<prefix>_<random> format
- Scopes: projects:read, projects:execute, keys:read, keys:write, admin
- SHA-256 key hashing (secrets never stored)
- Expiration options: 30d, 60d, 90d, 1y, never
- Middleware skips /health, /ready, /docs, /openapi.json
## Key Management API
- GET /keys - List keys (keys:read)
- POST /keys - Create key (keys:write)
- GET /keys/{id} - Get key details (keys:read)
- DELETE /keys/{id} - Revoke key (keys:write)
## Environment Variables
- DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
- RDEV_ADMIN_KEY - Super admin key for bootstrapping
Version bumped to 0.5.0.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0960b17eb2
commit
d2de49a591
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,7 +17,7 @@ Thumbs.db
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
*.tar
|
*.tar
|
||||||
*.gz
|
*.gz
|
||||||
rdev-api
|
/rdev-api
|
||||||
|
|
||||||
# Deploy keys (generated, never commit)
|
# Deploy keys (generated, never commit)
|
||||||
*-deploy-key
|
*-deploy-key
|
||||||
|
|||||||
@ -4,11 +4,20 @@
|
|||||||
// instances running in Kubernetes pods. External clients (Discord bots,
|
// instances running in Kubernetes pods. External clients (Discord bots,
|
||||||
// CLI tools, etc.) connect via this API.
|
// CLI tools, etc.) connect via this API.
|
||||||
//
|
//
|
||||||
|
// Authentication:
|
||||||
|
// - All endpoints (except /health, /ready, /docs) require X-API-Key header
|
||||||
|
// - Admin key from RDEV_ADMIN_KEY env var for key management
|
||||||
|
// - Create additional keys via POST /keys
|
||||||
|
//
|
||||||
// Endpoints:
|
// Endpoints:
|
||||||
// - GET /health - Health check
|
// - GET /health - Health check (no auth)
|
||||||
// - GET /ready - Readiness check
|
// - GET /ready - Readiness check (no auth)
|
||||||
// - GET /docs - Scalar API documentation
|
// - GET /docs - Scalar API documentation (no auth)
|
||||||
// - GET /openapi.json - OpenAPI 3.0 specification
|
// - GET /openapi.json - OpenAPI 3.0 specification (no auth)
|
||||||
|
// - GET /keys - List API keys
|
||||||
|
// - POST /keys - Create API key
|
||||||
|
// - GET /keys/{id} - Get API key details
|
||||||
|
// - DELETE /keys/{id} - Revoke API key
|
||||||
// - GET /projects - List available projects
|
// - GET /projects - List available projects
|
||||||
// - GET /projects/{id} - Get project details
|
// - GET /projects/{id} - Get project details
|
||||||
// - POST /projects/{id}/claude - Run Claude command
|
// - POST /projects/{id}/claude - Run Claude command
|
||||||
@ -18,73 +27,255 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/internal/auth"
|
||||||
|
"github.com/orchard9/rdev/internal/db"
|
||||||
"github.com/orchard9/rdev/internal/handlers"
|
"github.com/orchard9/rdev/internal/handlers"
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
app := api.New("rdev-api", api.WithPort(8080))
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Load configuration from environment
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
// Initialize database with auto-migrations
|
||||||
|
database, err := db.New(db.Config{
|
||||||
|
Host: cfg.DBHost,
|
||||||
|
Port: cfg.DBPort,
|
||||||
|
User: cfg.DBUser,
|
||||||
|
Password: cfg.DBPassword,
|
||||||
|
Database: cfg.DBName,
|
||||||
|
SSLMode: cfg.DBSSLMode,
|
||||||
|
}, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to connect to database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
// Initialize auth service
|
||||||
|
authService := auth.NewService(database.DB, cfg.AdminKey)
|
||||||
|
|
||||||
|
// Create app
|
||||||
|
app := api.New("rdev-api",
|
||||||
|
api.WithPort(cfg.Port),
|
||||||
|
api.WithLogger(logger),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add auth middleware (skips /health, /ready, /docs, /openapi.json)
|
||||||
|
app.Use(auth.Middleware(authService))
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
projectsHandler := handlers.NewProjectsHandler()
|
projectsHandler := handlers.NewProjectsHandler()
|
||||||
|
keysHandler := handlers.NewKeysHandler(authService)
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
projectsHandler.Mount(app.Router())
|
projectsHandler.Mount(app.Router())
|
||||||
|
keysHandler.Mount(app.Router())
|
||||||
|
|
||||||
// Enable API documentation
|
// Enable API documentation
|
||||||
app.EnableDocs(buildOpenAPISpec())
|
app.EnableDocs(buildOpenAPISpec())
|
||||||
|
|
||||||
|
// Cleanup on shutdown
|
||||||
|
app.OnShutdown(func(_ context.Context) error {
|
||||||
|
return database.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.Info("rdev-api starting",
|
||||||
|
"port", cfg.Port,
|
||||||
|
"db_host", cfg.DBHost,
|
||||||
|
"admin_key_set", cfg.AdminKey != "",
|
||||||
|
)
|
||||||
|
|
||||||
app.Run()
|
app.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Config holds application configuration.
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
DBHost string
|
||||||
|
DBPort int
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
DBSSLMode string
|
||||||
|
AdminKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
port := 8080
|
||||||
|
if v := os.Getenv("PORT"); v != "" {
|
||||||
|
if p, err := strconv.Atoi(v); err == nil {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPort := 5432
|
||||||
|
if v := os.Getenv("DB_PORT"); v != "" {
|
||||||
|
if p, err := strconv.Atoi(v); err == nil {
|
||||||
|
dbPort = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
Port: port,
|
||||||
|
DBHost: getEnv("DB_HOST", "postgres.databases.svc"),
|
||||||
|
DBPort: dbPort,
|
||||||
|
DBUser: getEnv("DB_USER", "appuser"),
|
||||||
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
||||||
|
DBName: getEnv("DB_NAME", "rdev"),
|
||||||
|
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
||||||
|
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultVal string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
|
||||||
// buildOpenAPISpec creates the OpenAPI specification for the rdev API.
|
// buildOpenAPISpec creates the OpenAPI specification for the rdev API.
|
||||||
func buildOpenAPISpec() *api.OpenAPISpec {
|
func buildOpenAPISpec() *api.OpenAPISpec {
|
||||||
spec := api.NewOpenAPISpec("rdev API", "0.4.0").
|
spec := api.NewOpenAPISpec("rdev API", "0.5.0").
|
||||||
WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes.
|
WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes.
|
||||||
|
|
||||||
rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming.
|
rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming.
|
||||||
External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects.
|
External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects.
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- **rdev-api**: This Go service (what you're looking at)
|
|
||||||
- **claudebox pods**: Isolated pods running Claude Code CLI
|
|
||||||
- **Projects**: Each project has its own claudebox pod with mounted workspace
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Authentication is planned for v0.6. Currently the API is internal-only.
|
All endpoints except /health, /ready, and /docs require authentication via API key.
|
||||||
|
|
||||||
|
**Header**: ` + "`X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`" + `
|
||||||
|
|
||||||
|
Or: ` + "`Authorization: Bearer rdev_sk_...`" + `
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Set RDEV_ADMIN_KEY environment variable with your super admin key
|
||||||
|
2. Use the admin key to create additional keys via POST /keys
|
||||||
|
3. Use created keys for normal operations
|
||||||
|
|
||||||
|
### Scopes
|
||||||
|
|
||||||
|
| Scope | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| projects:read | List and view projects |
|
||||||
|
| projects:execute | Run commands (claude, shell, git) |
|
||||||
|
| keys:read | List API keys (metadata only) |
|
||||||
|
| keys:write | Create and revoke keys |
|
||||||
|
| admin | Full access (all scopes) |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **rdev-api**: This Go service
|
||||||
|
- **claudebox pods**: Isolated pods running Claude Code CLI
|
||||||
|
- **postgres**: API key storage (auto-migrating)
|
||||||
|
|
||||||
## Streaming
|
## Streaming
|
||||||
|
|
||||||
Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/events endpoint.
|
Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events.
|
||||||
`).
|
`).
|
||||||
WithServer("http://localhost:8080", "Local development").
|
WithServer("http://localhost:8080", "Local development").
|
||||||
WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal")
|
WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal")
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
|
spec.WithTag("Authentication", "API key management")
|
||||||
spec.WithTag("Projects", "Project management and discovery")
|
spec.WithTag("Projects", "Project management and discovery")
|
||||||
spec.WithTag("Commands", "Command execution (claude, shell, git)")
|
spec.WithTag("Commands", "Command execution (claude, shell, git)")
|
||||||
spec.WithTag("Events", "Server-Sent Events for real-time output")
|
spec.WithTag("Events", "Server-Sent Events for real-time output")
|
||||||
spec.WithTag("System", "Health and readiness endpoints")
|
spec.WithTag("System", "Health and readiness endpoints")
|
||||||
|
|
||||||
// System endpoints (auto-added by chassis, but document them)
|
// System endpoints
|
||||||
spec.AddPath("/health", "get", api.Op(
|
spec.AddPath("/health", "get", api.Op(
|
||||||
"Health check",
|
"Health check",
|
||||||
"Returns the health status of the rdev-api service",
|
"Returns health status. No authentication required.",
|
||||||
"System",
|
"System",
|
||||||
))
|
))
|
||||||
spec.AddPath("/ready", "get", api.Op(
|
spec.AddPath("/ready", "get", api.Op(
|
||||||
"Readiness check",
|
"Readiness check",
|
||||||
"Returns whether the service is ready to accept traffic",
|
"Returns readiness status. No authentication required.",
|
||||||
"System",
|
"System",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// Key management endpoints
|
||||||
|
spec.AddPath("/keys", "get", withAuth(
|
||||||
|
"List API keys",
|
||||||
|
"Returns all API keys with metadata (not secrets). Requires keys:read scope.",
|
||||||
|
"Authentication",
|
||||||
|
"keys:read",
|
||||||
|
`[
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "discord-bot",
|
||||||
|
"key_prefix": "a1b2c3d4",
|
||||||
|
"scopes": ["projects:read", "projects:execute"],
|
||||||
|
"created_at": "2026-01-24T20:00:00Z",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
]`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/keys", "post", withAuthAndBody(
|
||||||
|
"Create API key",
|
||||||
|
`Creates a new API key. The secret is returned only once - save it securely!
|
||||||
|
|
||||||
|
**Expiration options**: 30d, 60d, 90d, 1y, never (default: never)`,
|
||||||
|
"Authentication",
|
||||||
|
"keys:write",
|
||||||
|
`{
|
||||||
|
"name": "discord-bot",
|
||||||
|
"scopes": ["projects:read", "projects:execute"],
|
||||||
|
"project_ids": ["pantheon"],
|
||||||
|
"expires_in": "90d"
|
||||||
|
}`,
|
||||||
|
`{
|
||||||
|
"key": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "discord-bot",
|
||||||
|
"key_prefix": "a1b2c3d4",
|
||||||
|
"scopes": ["projects:read", "projects:execute"],
|
||||||
|
"project_ids": ["pantheon"],
|
||||||
|
"created_at": "2026-01-24T20:00:00Z",
|
||||||
|
"expires_at": "2026-04-24T20:00:00Z",
|
||||||
|
"active": true
|
||||||
|
},
|
||||||
|
"secret": "rdev_sk_a1b2c3d4_e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
|
||||||
|
}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/keys/{id}", "get", withAuthAndParams(
|
||||||
|
"Get API key",
|
||||||
|
"Returns details for a specific API key. Requires keys:read scope.",
|
||||||
|
"Authentication",
|
||||||
|
"keys:read",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
|
spec.AddPath("/keys/{id}", "delete", withAuthAndParams(
|
||||||
|
"Revoke API key",
|
||||||
|
"Revokes an API key immediately. The key cannot be used after revocation. Requires keys:write scope.",
|
||||||
|
"Authentication",
|
||||||
|
"keys:write",
|
||||||
|
[]param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}},
|
||||||
|
))
|
||||||
|
|
||||||
// Projects - List and Get
|
// Projects - List and Get
|
||||||
spec.AddPath("/projects", "get", withResponse(
|
spec.AddPath("/projects", "get", withAuth(
|
||||||
"List projects",
|
"List projects",
|
||||||
"Returns all available projects (claudebox pods) in the cluster",
|
"Returns all available projects (claudebox pods). Requires projects:read scope.",
|
||||||
"Projects",
|
"Projects",
|
||||||
|
"projects:read",
|
||||||
`[
|
`[
|
||||||
{
|
{
|
||||||
"id": "pantheon",
|
"id": "pantheon",
|
||||||
@ -96,18 +287,20 @@ Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/ev
|
|||||||
]`,
|
]`,
|
||||||
))
|
))
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}", "get", withParams(
|
spec.AddPath("/projects/{id}", "get", withAuthAndParams(
|
||||||
"Get project",
|
"Get project",
|
||||||
"Returns details for a specific project including its configuration",
|
"Returns details for a specific project. Requires projects:read scope.",
|
||||||
"Projects",
|
"Projects",
|
||||||
|
"projects:read",
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}},
|
[]param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}},
|
||||||
))
|
))
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
spec.AddPath("/projects/{id}/claude", "post", withRequestBody(
|
spec.AddPath("/projects/{id}/claude", "post", withAuthBodyAndParams(
|
||||||
"Run Claude command",
|
"Run Claude command",
|
||||||
"Executes a Claude Code prompt in the project's claudebox pod. Returns a command ID for tracking via SSE.",
|
"Executes a Claude Code prompt in the project's claudebox pod. Requires projects:execute scope.",
|
||||||
"Commands",
|
"Commands",
|
||||||
|
"projects:execute",
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
`{
|
`{
|
||||||
"prompt": "fix the bug in auth handler",
|
"prompt": "fix the bug in auth handler",
|
||||||
@ -122,10 +315,11 @@ Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/ev
|
|||||||
}`,
|
}`,
|
||||||
))
|
))
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/shell", "post", withRequestBody(
|
spec.AddPath("/projects/{id}/shell", "post", withAuthBodyAndParams(
|
||||||
"Run shell command",
|
"Run shell command",
|
||||||
"Executes a shell command in the project's claudebox pod. Use for running tests, builds, etc.",
|
"Executes a shell command in the project's claudebox pod. Requires projects:execute scope.",
|
||||||
"Commands",
|
"Commands",
|
||||||
|
"projects:execute",
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
`{
|
`{
|
||||||
"command": "go test ./...",
|
"command": "go test ./...",
|
||||||
@ -140,10 +334,11 @@ Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/ev
|
|||||||
}`,
|
}`,
|
||||||
))
|
))
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/git", "post", withRequestBody(
|
spec.AddPath("/projects/{id}/git", "post", withAuthBodyAndParams(
|
||||||
"Run git command",
|
"Run git command",
|
||||||
"Executes a git command in the project's claudebox pod. Use for commits, pushes, pulls, etc.",
|
"Executes a git command in the project's claudebox pod. Requires projects:execute scope.",
|
||||||
"Commands",
|
"Commands",
|
||||||
|
"projects:execute",
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
||||||
`{
|
`{
|
||||||
"args": ["status"],
|
"args": ["status"],
|
||||||
@ -163,7 +358,7 @@ Command output is streamed via Server-Sent Events (SSE) at the /projects/{id}/ev
|
|||||||
"summary": "Stream events",
|
"summary": "Stream events",
|
||||||
"description": `Server-Sent Events stream for real-time command output.
|
"description": `Server-Sent Events stream for real-time command output.
|
||||||
|
|
||||||
Connect to this endpoint to receive output as commands execute.
|
Requires projects:read scope.
|
||||||
|
|
||||||
## Event Types
|
## Event Types
|
||||||
|
|
||||||
@ -176,7 +371,9 @@ Connect to this endpoint to receive output as commands execute.
|
|||||||
## Example
|
## Example
|
||||||
|
|
||||||
` + "```javascript" + `
|
` + "```javascript" + `
|
||||||
const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001');
|
const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001', {
|
||||||
|
headers: { 'X-API-Key': 'rdev_sk_...' }
|
||||||
|
});
|
||||||
|
|
||||||
events.addEventListener('output', (e) => {
|
events.addEventListener('output', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
@ -190,6 +387,9 @@ events.addEventListener('complete', (e) => {
|
|||||||
});
|
});
|
||||||
` + "```",
|
` + "```",
|
||||||
"tags": []string{"Events"},
|
"tags": []string{"Events"},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
"parameters": []map[string]any{
|
"parameters": []map[string]any{
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@ -232,12 +432,15 @@ type param struct {
|
|||||||
Required bool
|
Required bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// withResponse creates an operation with example response.
|
// withAuth creates an operation that requires authentication.
|
||||||
func withResponse(summary, description, tag, example string) map[string]any {
|
func withAuth(summary, description, tag, scope, example string) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description,
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
"tags": []string{tag},
|
"tags": []string{tag},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
"responses": map[string]any{
|
"responses": map[string]any{
|
||||||
"200": map[string]any{
|
"200": map[string]any{
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@ -247,50 +450,21 @@ func withResponse(summary, description, tag, example string) map[string]any {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// withParams creates an operation with path parameters.
|
// withAuthAndBody creates an operation with auth and request body.
|
||||||
func withParams(summary, description, tag string, params []param) map[string]any {
|
func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any {
|
||||||
parameters := make([]map[string]any, len(params))
|
|
||||||
for i, p := range params {
|
|
||||||
parameters[i] = map[string]any{
|
|
||||||
"name": p.Name,
|
|
||||||
"in": p.In,
|
|
||||||
"description": p.Description,
|
|
||||||
"required": p.Required,
|
|
||||||
"schema": map[string]any{"type": "string"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"description": description,
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
"tags": []string{tag},
|
"tags": []string{tag},
|
||||||
"parameters": parameters,
|
"security": []map[string]any{
|
||||||
"responses": map[string]any{
|
{"ApiKeyAuth": []string{}},
|
||||||
"200": map[string]any{"description": "Success"},
|
|
||||||
},
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withRequestBody creates an operation with request body and example response.
|
|
||||||
func withRequestBody(summary, description, tag string, params []param, requestExample, responseExample string) map[string]any {
|
|
||||||
parameters := make([]map[string]any, len(params))
|
|
||||||
for i, p := range params {
|
|
||||||
parameters[i] = map[string]any{
|
|
||||||
"name": p.Name,
|
|
||||||
"in": p.In,
|
|
||||||
"description": p.Description,
|
|
||||||
"required": p.Required,
|
|
||||||
"schema": map[string]any{"type": "string"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"summary": summary,
|
|
||||||
"description": description,
|
|
||||||
"tags": []string{tag},
|
|
||||||
"parameters": parameters,
|
|
||||||
"requestBody": map[string]any{
|
"requestBody": map[string]any{
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": map[string]any{
|
"content": map[string]any{
|
||||||
@ -301,13 +475,89 @@ func withRequestBody(summary, description, tag string, params []param, requestEx
|
|||||||
},
|
},
|
||||||
"responses": map[string]any{
|
"responses": map[string]any{
|
||||||
"201": map[string]any{
|
"201": map[string]any{
|
||||||
"description": "Command queued",
|
"description": "Created",
|
||||||
"content": map[string]any{
|
"content": map[string]any{
|
||||||
"application/json": map[string]any{
|
"application/json": map[string]any{
|
||||||
"example": responseExample,
|
"example": responseExample,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"400": map[string]any{"description": "Bad Request - Invalid input"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthAndParams creates an operation with auth and path parameters.
|
||||||
|
func withAuthAndParams(summary, description, tag, scope string, params []param) map[string]any {
|
||||||
|
parameters := make([]map[string]any, len(params))
|
||||||
|
for i, p := range params {
|
||||||
|
parameters[i] = map[string]any{
|
||||||
|
"name": p.Name,
|
||||||
|
"in": p.In,
|
||||||
|
"description": p.Description,
|
||||||
|
"required": p.Required,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"summary": summary,
|
||||||
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
|
"tags": []string{tag},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": parameters,
|
||||||
|
"responses": map[string]any{
|
||||||
|
"200": map[string]any{"description": "Success"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
|
"404": map[string]any{"description": "Not Found"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withAuthBodyAndParams creates an operation with auth, body, and params.
|
||||||
|
func withAuthBodyAndParams(summary, description, tag, scope string, params []param, requestExample, responseExample string) map[string]any {
|
||||||
|
parameters := make([]map[string]any, len(params))
|
||||||
|
for i, p := range params {
|
||||||
|
parameters[i] = map[string]any{
|
||||||
|
"name": p.Name,
|
||||||
|
"in": p.In,
|
||||||
|
"description": p.Description,
|
||||||
|
"required": p.Required,
|
||||||
|
"schema": map[string]any{"type": "string"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"summary": summary,
|
||||||
|
"description": description + "\n\n**Required scope**: `" + scope + "`",
|
||||||
|
"tags": []string{tag},
|
||||||
|
"security": []map[string]any{
|
||||||
|
{"ApiKeyAuth": []string{}},
|
||||||
|
},
|
||||||
|
"parameters": parameters,
|
||||||
|
"requestBody": map[string]any{
|
||||||
|
"required": true,
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"example": requestExample,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"responses": map[string]any{
|
||||||
|
"201": map[string]any{
|
||||||
|
"description": "Created",
|
||||||
|
"content": map[string]any{
|
||||||
|
"application/json": map[string]any{
|
||||||
|
"example": responseExample,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": map[string]any{"description": "Bad Request - Invalid input"},
|
||||||
|
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
|
||||||
|
"403": map[string]any{"description": "Forbidden - Insufficient permissions"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -5,6 +5,7 @@ go 1.23
|
|||||||
require (
|
require (
|
||||||
github.com/bdpiprava/scalar-go v0.13.0
|
github.com/bdpiprava/scalar-go v0.13.0
|
||||||
github.com/go-chi/chi/v5 v5.1.0
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
)
|
)
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
|||||||
123
internal/auth/keys.go
Normal file
123
internal/auth/keys.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// Package auth provides API key authentication for rdev.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// KeyPrefix is the prefix for all rdev API keys.
|
||||||
|
KeyPrefix = "rdev_sk_"
|
||||||
|
|
||||||
|
// KeyRandomLength is the length of the random portion of the key.
|
||||||
|
KeyRandomLength = 32
|
||||||
|
|
||||||
|
// KeyIdentifierLength is the length of the identifier portion.
|
||||||
|
KeyIdentifierLength = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expiration durations for API keys.
|
||||||
|
var (
|
||||||
|
Expiration30Days = 30 * 24 * time.Hour
|
||||||
|
Expiration60Days = 60 * 24 * time.Hour
|
||||||
|
Expiration90Days = 90 * 24 * time.Hour
|
||||||
|
Expiration1Year = 365 * 24 * time.Hour
|
||||||
|
ExpirationNoLimit time.Duration = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseExpiration converts an expiration string to duration.
|
||||||
|
// Valid values: "30d", "60d", "90d", "1y", "never"
|
||||||
|
func ParseExpiration(s string) (time.Duration, error) {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "30d", "30":
|
||||||
|
return Expiration30Days, nil
|
||||||
|
case "60d", "60":
|
||||||
|
return Expiration60Days, nil
|
||||||
|
case "90d", "90":
|
||||||
|
return Expiration90Days, nil
|
||||||
|
case "1y", "1year", "365d":
|
||||||
|
return Expiration1Year, nil
|
||||||
|
case "never", "none", "":
|
||||||
|
return ExpirationNoLimit, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid expiration: %s (use 30d, 60d, 90d, 1y, or never)", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiresAt calculates the expiration timestamp from a duration.
|
||||||
|
// Returns nil if duration is 0 (no expiration).
|
||||||
|
func ExpiresAt(d time.Duration) *time.Time {
|
||||||
|
if d == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.Now().Add(d)
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKey creates a new API key with format: rdev_sk_<identifier>_<random>
|
||||||
|
// Returns the full key (to show once) and the identifier (for storage).
|
||||||
|
func GenerateKey() (fullKey, identifier string, err error) {
|
||||||
|
// Generate identifier (8 chars)
|
||||||
|
idBytes := make([]byte, KeyIdentifierLength/2)
|
||||||
|
if _, err := rand.Read(idBytes); err != nil {
|
||||||
|
return "", "", fmt.Errorf("generate identifier: %w", err)
|
||||||
|
}
|
||||||
|
identifier = hex.EncodeToString(idBytes)
|
||||||
|
|
||||||
|
// Generate random portion (32 chars)
|
||||||
|
randomBytes := make([]byte, KeyRandomLength/2)
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", "", fmt.Errorf("generate random: %w", err)
|
||||||
|
}
|
||||||
|
random := hex.EncodeToString(randomBytes)
|
||||||
|
|
||||||
|
fullKey = fmt.Sprintf("%s%s_%s", KeyPrefix, identifier, random)
|
||||||
|
return fullKey, identifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashKey computes the SHA-256 hash of an API key.
|
||||||
|
func HashKey(key string) string {
|
||||||
|
h := sha256.Sum256([]byte(key))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateKeyFormat checks if a key has the correct format.
|
||||||
|
func ValidateKeyFormat(key string) bool {
|
||||||
|
if !strings.HasPrefix(key, KeyPrefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: rdev_sk_<8-char-id>_<32-char-random>
|
||||||
|
parts := strings.Split(strings.TrimPrefix(key, KeyPrefix), "_")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts[0]) != KeyIdentifierLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts[1]) != KeyRandomLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractPrefix extracts the identifier prefix from a key.
|
||||||
|
func ExtractPrefix(key string) string {
|
||||||
|
if !strings.HasPrefix(key, KeyPrefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimPrefix(key, KeyPrefix)
|
||||||
|
parts := strings.Split(trimmed, "_")
|
||||||
|
if len(parts) < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
139
internal/auth/middleware.go
Normal file
139
internal/auth/middleware.go
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Header for API key authentication.
|
||||||
|
const HeaderAPIKey = "X-API-Key"
|
||||||
|
|
||||||
|
// Context keys.
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
contextKeyAPIKey contextKey = "api_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAPIKey retrieves the authenticated API key from the request context.
|
||||||
|
func GetAPIKey(ctx context.Context) *APIKey {
|
||||||
|
key, _ := ctx.Value(contextKeyAPIKey).(*APIKey)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware creates an authentication middleware.
|
||||||
|
func Middleware(svc *Service) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Skip auth for health endpoints
|
||||||
|
if r.URL.Path == "/health" || r.URL.Path == "/ready" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip auth for docs
|
||||||
|
if r.URL.Path == "/docs" || r.URL.Path == "/openapi.json" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get key from header
|
||||||
|
key := r.Header.Get(HeaderAPIKey)
|
||||||
|
if key == "" {
|
||||||
|
// Also check Authorization: Bearer
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
key = strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Missing API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key
|
||||||
|
apiKey, err := svc.Validate(r.Context(), key)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrKeyNotFound) {
|
||||||
|
api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Invalid API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrKeyRevoked) {
|
||||||
|
api.WriteError(w, r, http.StatusUnauthorized, "KEY_REVOKED", "API key has been revoked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrKeyExpired) {
|
||||||
|
api.WriteError(w, r, http.StatusUnauthorized, "KEY_EXPIRED", "API key has expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.WriteError(w, r, http.StatusInternalServerError, "AUTH_ERROR", "Authentication failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add key to context
|
||||||
|
ctx := context.WithValue(r.Context(), contextKeyAPIKey, apiKey)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireScope creates a middleware that checks for required scopes.
|
||||||
|
func RequireScope(required ...Scope) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apiKey := GetAPIKey(r.Context())
|
||||||
|
if apiKey == nil {
|
||||||
|
api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !HasAnyScope(apiKey.Scopes, required...) {
|
||||||
|
api.WriteError(w, r, http.StatusForbidden, "FORBIDDEN",
|
||||||
|
"Insufficient permissions. Required: "+scopesToString(required))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireProjectAccess creates a middleware that checks project access.
|
||||||
|
// projectIDParam is the URL parameter name containing the project ID.
|
||||||
|
func RequireProjectAccess(projectIDParam string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
apiKey := GetAPIKey(r.Context())
|
||||||
|
if apiKey == nil {
|
||||||
|
api.WriteError(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin has access to everything
|
||||||
|
if HasScope(apiKey.Scopes, ScopeAdmin) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project ID from URL
|
||||||
|
// Using chi's URLParam would require importing chi here
|
||||||
|
// Instead, we'll extract from path in the handler
|
||||||
|
// This middleware just validates the key has project restrictions
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scopesToString(scopes []Scope) string {
|
||||||
|
ss := make([]string, len(scopes))
|
||||||
|
for i, s := range scopes {
|
||||||
|
ss[i] = string(s)
|
||||||
|
}
|
||||||
|
return strings.Join(ss, ", ")
|
||||||
|
}
|
||||||
101
internal/auth/scopes.go
Normal file
101
internal/auth/scopes.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// Scope represents an API permission scope.
|
||||||
|
type Scope string
|
||||||
|
|
||||||
|
// Available scopes.
|
||||||
|
const (
|
||||||
|
ScopeProjectsRead Scope = "projects:read"
|
||||||
|
ScopeProjectsExecute Scope = "projects:execute"
|
||||||
|
ScopeKeysRead Scope = "keys:read"
|
||||||
|
ScopeKeysWrite Scope = "keys:write"
|
||||||
|
ScopeAdmin Scope = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllScopes is the list of all valid scopes.
|
||||||
|
var AllScopes = []Scope{
|
||||||
|
ScopeProjectsRead,
|
||||||
|
ScopeProjectsExecute,
|
||||||
|
ScopeKeysRead,
|
||||||
|
ScopeKeysWrite,
|
||||||
|
ScopeAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopeDescriptions provides human-readable descriptions.
|
||||||
|
var ScopeDescriptions = map[Scope]string{
|
||||||
|
ScopeProjectsRead: "List and view project details",
|
||||||
|
ScopeProjectsExecute: "Execute commands (claude, shell, git) on projects",
|
||||||
|
ScopeKeysRead: "List API keys (metadata only, not secrets)",
|
||||||
|
ScopeKeysWrite: "Create and revoke API keys",
|
||||||
|
ScopeAdmin: "Full administrative access (includes all scopes)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid checks if a scope is valid.
|
||||||
|
func (s Scope) IsValid() bool {
|
||||||
|
return slices.Contains(AllScopes, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the scope as a string.
|
||||||
|
func (s Scope) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopesFromStrings converts string slice to Scope slice.
|
||||||
|
func ScopesFromStrings(ss []string) []Scope {
|
||||||
|
scopes := make([]Scope, len(ss))
|
||||||
|
for i, s := range ss {
|
||||||
|
scopes[i] = Scope(s)
|
||||||
|
}
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScopesToStrings converts Scope slice to string slice.
|
||||||
|
func ScopesToStrings(scopes []Scope) []string {
|
||||||
|
ss := make([]string, len(scopes))
|
||||||
|
for i, s := range scopes {
|
||||||
|
ss[i] = string(s)
|
||||||
|
}
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateScopes checks if all scopes are valid.
|
||||||
|
func ValidateScopes(scopes []Scope) bool {
|
||||||
|
for _, s := range scopes {
|
||||||
|
if !s.IsValid() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasScope checks if a scope list contains a required scope.
|
||||||
|
// Admin scope grants access to everything.
|
||||||
|
func HasScope(scopes []Scope, required Scope) bool {
|
||||||
|
for _, s := range scopes {
|
||||||
|
if s == ScopeAdmin || s == required {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasAnyScope checks if a scope list contains any of the required scopes.
|
||||||
|
func HasAnyScope(scopes []Scope, required ...Scope) bool {
|
||||||
|
for _, r := range required {
|
||||||
|
if HasScope(scopes, r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasProjectAccess checks if the key has access to a specific project.
|
||||||
|
// projectIDs nil means access to all projects.
|
||||||
|
func HasProjectAccess(allowedProjects []string, projectID string) bool {
|
||||||
|
if allowedProjects == nil {
|
||||||
|
return true // nil = all projects
|
||||||
|
}
|
||||||
|
return slices.Contains(allowedProjects, projectID)
|
||||||
|
}
|
||||||
296
internal/auth/service.go
Normal file
296
internal/auth/service.go
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Common errors.
|
||||||
|
var (
|
||||||
|
ErrKeyNotFound = errors.New("api key not found")
|
||||||
|
ErrKeyRevoked = errors.New("api key has been revoked")
|
||||||
|
ErrKeyExpired = errors.New("api key has expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIKey represents a stored API key.
|
||||||
|
type APIKey struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
KeyPrefix string
|
||||||
|
Scopes []Scope
|
||||||
|
ProjectIDs []string // nil = all projects
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt *time.Time
|
||||||
|
LastUsedAt *time.Time
|
||||||
|
RevokedAt *time.Time
|
||||||
|
CreatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpired checks if the key has expired.
|
||||||
|
func (k *APIKey) IsExpired() bool {
|
||||||
|
if k.ExpiresAt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().After(*k.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRevoked checks if the key has been revoked.
|
||||||
|
func (k *APIKey) IsRevoked() bool {
|
||||||
|
return k.RevokedAt != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive checks if the key is valid for use.
|
||||||
|
func (k *APIKey) IsActive() bool {
|
||||||
|
return !k.IsRevoked() && !k.IsExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeyRequest is the input for creating a new key.
|
||||||
|
type CreateKeyRequest struct {
|
||||||
|
Name string
|
||||||
|
Scopes []Scope
|
||||||
|
ProjectIDs []string // nil = all projects
|
||||||
|
ExpiresIn time.Duration // 0 = never
|
||||||
|
CreatedBy string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeyResponse is the output of creating a new key.
|
||||||
|
type CreateKeyResponse struct {
|
||||||
|
Key *APIKey
|
||||||
|
Secret string // Full key, shown only once
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service handles API key operations.
|
||||||
|
type Service struct {
|
||||||
|
db *sql.DB
|
||||||
|
adminKey string // Super admin key from environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new auth service.
|
||||||
|
func NewService(db *sql.DB, adminKey string) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
adminKey: adminKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdminKey checks if the provided key is the super admin key.
|
||||||
|
func (s *Service) IsAdminKey(key string) bool {
|
||||||
|
return s.adminKey != "" && key == s.adminKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create generates a new API key.
|
||||||
|
func (s *Service) Create(ctx context.Context, req CreateKeyRequest) (*CreateKeyResponse, error) {
|
||||||
|
// Validate scopes
|
||||||
|
if !ValidateScopes(req.Scopes) {
|
||||||
|
return nil, fmt.Errorf("invalid scopes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate key
|
||||||
|
fullKey, prefix, err := GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyHash := HashKey(fullKey)
|
||||||
|
expiresAt := ExpiresAt(req.ExpiresIn)
|
||||||
|
|
||||||
|
// Convert scopes to strings for postgres
|
||||||
|
scopeStrings := ScopesToStrings(req.Scopes)
|
||||||
|
|
||||||
|
var id string
|
||||||
|
err = s.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO api_keys (name, key_hash, key_prefix, scopes, project_ids, expires_at, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`, req.Name, keyHash, prefix, pq.Array(scopeStrings), pq.Array(req.ProjectIDs), expiresAt, req.CreatedBy).Scan(&id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := &APIKey{
|
||||||
|
ID: id,
|
||||||
|
Name: req.Name,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
Scopes: req.Scopes,
|
||||||
|
ProjectIDs: req.ProjectIDs,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
CreatedBy: req.CreatedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CreateKeyResponse{
|
||||||
|
Key: key,
|
||||||
|
Secret: fullKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a key is valid and returns the key details.
|
||||||
|
func (s *Service) Validate(ctx context.Context, key string) (*APIKey, error) {
|
||||||
|
// Check admin key first
|
||||||
|
if s.IsAdminKey(key) {
|
||||||
|
return &APIKey{
|
||||||
|
ID: "admin",
|
||||||
|
Name: "Super Admin",
|
||||||
|
KeyPrefix: "admin",
|
||||||
|
Scopes: []Scope{ScopeAdmin},
|
||||||
|
CreatedAt: time.Time{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format
|
||||||
|
if !ValidateKeyFormat(key) {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
keyHash := HashKey(key)
|
||||||
|
|
||||||
|
var (
|
||||||
|
apiKey APIKey
|
||||||
|
scopeStrings []string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by
|
||||||
|
FROM api_keys
|
||||||
|
WHERE key_hash = $1
|
||||||
|
`, keyHash).Scan(
|
||||||
|
&apiKey.ID,
|
||||||
|
&apiKey.Name,
|
||||||
|
&apiKey.KeyPrefix,
|
||||||
|
pq.Array(&scopeStrings),
|
||||||
|
pq.Array(&apiKey.ProjectIDs),
|
||||||
|
&apiKey.CreatedAt,
|
||||||
|
&apiKey.ExpiresAt,
|
||||||
|
&apiKey.LastUsedAt,
|
||||||
|
&apiKey.RevokedAt,
|
||||||
|
&apiKey.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey.Scopes = ScopesFromStrings(scopeStrings)
|
||||||
|
|
||||||
|
if apiKey.IsRevoked() {
|
||||||
|
return nil, ErrKeyRevoked
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey.IsExpired() {
|
||||||
|
return nil, ErrKeyExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_used_at asynchronously
|
||||||
|
go func() {
|
||||||
|
s.db.ExecContext(context.Background(), `
|
||||||
|
UPDATE api_keys SET last_used_at = NOW() WHERE id = $1
|
||||||
|
`, apiKey.ID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all API keys (without secrets).
|
||||||
|
func (s *Service) List(ctx context.Context) ([]*APIKey, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by
|
||||||
|
FROM api_keys
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query keys: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keys []*APIKey
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
key APIKey
|
||||||
|
scopeStrings []string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(
|
||||||
|
&key.ID,
|
||||||
|
&key.Name,
|
||||||
|
&key.KeyPrefix,
|
||||||
|
pq.Array(&scopeStrings),
|
||||||
|
pq.Array(&key.ProjectIDs),
|
||||||
|
&key.CreatedAt,
|
||||||
|
&key.ExpiresAt,
|
||||||
|
&key.LastUsedAt,
|
||||||
|
&key.RevokedAt,
|
||||||
|
&key.CreatedBy,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan key: %w", err)
|
||||||
|
}
|
||||||
|
key.Scopes = ScopesFromStrings(scopeStrings)
|
||||||
|
keys = append(keys, &key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single API key by ID.
|
||||||
|
func (s *Service) Get(ctx context.Context, id string) (*APIKey, error) {
|
||||||
|
var (
|
||||||
|
key APIKey
|
||||||
|
scopeStrings []string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by
|
||||||
|
FROM api_keys
|
||||||
|
WHERE id = $1
|
||||||
|
`, id).Scan(
|
||||||
|
&key.ID,
|
||||||
|
&key.Name,
|
||||||
|
&key.KeyPrefix,
|
||||||
|
pq.Array(&scopeStrings),
|
||||||
|
pq.Array(&key.ProjectIDs),
|
||||||
|
&key.CreatedAt,
|
||||||
|
&key.ExpiresAt,
|
||||||
|
&key.LastUsedAt,
|
||||||
|
&key.RevokedAt,
|
||||||
|
&key.CreatedBy,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrKeyNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key.Scopes = ScopesFromStrings(scopeStrings)
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke marks an API key as revoked.
|
||||||
|
func (s *Service) Revoke(ctx context.Context, id string) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, `
|
||||||
|
UPDATE api_keys SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL
|
||||||
|
`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("revoke key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rows affected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return ErrKeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
30
internal/db/migrations/001_create_api_keys.sql
Normal file
30
internal/db/migrations/001_create_api_keys.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- API Keys table for rdev authentication
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
key_prefix VARCHAR(8) NOT NULL,
|
||||||
|
scopes TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
project_ids TEXT[] DEFAULT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_by VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for key lookup (most common operation)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash ON api_keys(key_hash);
|
||||||
|
|
||||||
|
-- Index for listing active keys
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(revoked_at) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
-- Index for cleanup of expired keys
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_expires ON api_keys(expires_at) WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE api_keys IS 'API keys for authenticating rdev API requests';
|
||||||
|
COMMENT ON COLUMN api_keys.key_hash IS 'SHA-256 hash of the full API key';
|
||||||
|
COMMENT ON COLUMN api_keys.key_prefix IS 'First 8 chars of key for identification (rdev_sk_XXXXXXXX)';
|
||||||
|
COMMENT ON COLUMN api_keys.scopes IS 'Array of permission scopes: projects:read, projects:execute, keys:read, keys:write, admin';
|
||||||
|
COMMENT ON COLUMN api_keys.project_ids IS 'NULL = all projects, otherwise array of allowed project IDs';
|
||||||
|
COMMENT ON COLUMN api_keys.created_by IS 'admin or UUID of key that created this key';
|
||||||
172
internal/db/postgres.go
Normal file
172
internal/db/postgres.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// Package db provides database connectivity and migrations.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// Config holds database connection configuration.
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
SSLMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns config from environment defaults.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Host: "postgres.databases.svc",
|
||||||
|
Port: 5432,
|
||||||
|
User: "appuser",
|
||||||
|
Password: "",
|
||||||
|
Database: "rdev",
|
||||||
|
SSLMode: "disable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSN returns the connection string.
|
||||||
|
func (c Config) DSN() string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||||
|
c.Host, c.Port, c.User, c.Password, c.Database, c.SSLMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB wraps the sql.DB with additional functionality.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new database connection and runs migrations.
|
||||||
|
func New(cfg Config, logger *slog.Logger) (*DB, error) {
|
||||||
|
db, err := sql.Open("postgres", cfg.DSN())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure connection pool
|
||||||
|
db.SetMaxOpenConns(10)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped := &DB{DB: db, logger: logger}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := wrapped.migrate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrate runs all pending migrations.
|
||||||
|
func (db *DB) migrate() error {
|
||||||
|
// Create migrations table if not exists
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get applied migrations
|
||||||
|
applied := make(map[string]bool)
|
||||||
|
rows, err := db.Query("SELECT version FROM schema_migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query migrations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var version string
|
||||||
|
if err := rows.Scan(&version); err != nil {
|
||||||
|
return fmt.Errorf("scan version: %w", err)
|
||||||
|
}
|
||||||
|
applied[version] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read migration files
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by filename (version order)
|
||||||
|
var files []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
files = append(files, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
// Apply pending migrations
|
||||||
|
for _, file := range files {
|
||||||
|
version := strings.TrimSuffix(file, ".sql")
|
||||||
|
if applied[version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := migrationsFS.ReadFile("migrations/" + file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", file, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.logger.Info("applying migration", "version", version)
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("exec migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.logger.Info("applied migration", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.DB.Close()
|
||||||
|
}
|
||||||
206
internal/handlers/keys.go
Normal file
206
internal/handlers/keys.go
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/auth"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeysHandler handles API key management endpoints.
|
||||||
|
type KeysHandler struct {
|
||||||
|
authService *auth.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeysHandler creates a new keys handler.
|
||||||
|
func NewKeysHandler(authService *auth.Service) *KeysHandler {
|
||||||
|
return &KeysHandler{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount registers the keys routes.
|
||||||
|
func (h *KeysHandler) Mount(r api.Router) {
|
||||||
|
r.Route("/keys", func(r chi.Router) {
|
||||||
|
// All key endpoints require authentication (handled by global middleware)
|
||||||
|
r.With(auth.RequireScope(auth.ScopeKeysRead, auth.ScopeAdmin)).Get("/", h.List)
|
||||||
|
r.With(auth.RequireScope(auth.ScopeKeysWrite, auth.ScopeAdmin)).Post("/", h.Create)
|
||||||
|
r.With(auth.RequireScope(auth.ScopeKeysRead, auth.ScopeAdmin)).Get("/{id}", h.Get)
|
||||||
|
r.With(auth.RequireScope(auth.ScopeKeysWrite, auth.ScopeAdmin)).Delete("/{id}", h.Revoke)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeyRequest is the JSON body for creating a key.
|
||||||
|
type CreateKeyRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
ProjectIDs []string `json:"project_ids,omitempty"` // null = all projects
|
||||||
|
ExpiresIn string `json:"expires_in,omitempty"` // "30d", "60d", "90d", "1y", "never"
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyResponse is the JSON response for a key (without secret).
|
||||||
|
type KeyResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KeyPrefix string `json:"key_prefix"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
ProjectIDs []string `json:"project_ids,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ExpiresAt *string `json:"expires_at,omitempty"`
|
||||||
|
LastUsedAt *string `json:"last_used_at,omitempty"`
|
||||||
|
RevokedAt *string `json:"revoked_at,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeyResponse includes the secret (shown only once).
|
||||||
|
type CreateKeyResponse struct {
|
||||||
|
Key KeyResponse `json:"key"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeyToResponse converts an APIKey to a JSON response.
|
||||||
|
func apiKeyToResponse(k *auth.APIKey) KeyResponse {
|
||||||
|
resp := KeyResponse{
|
||||||
|
ID: k.ID,
|
||||||
|
Name: k.Name,
|
||||||
|
KeyPrefix: k.KeyPrefix,
|
||||||
|
Scopes: auth.ScopesToStrings(k.Scopes),
|
||||||
|
CreatedAt: k.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
CreatedBy: k.CreatedBy,
|
||||||
|
Active: k.IsActive(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.ProjectIDs != nil {
|
||||||
|
resp.ProjectIDs = k.ProjectIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.ExpiresAt != nil {
|
||||||
|
s := k.ExpiresAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
resp.ExpiresAt = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.LastUsedAt != nil {
|
||||||
|
s := k.LastUsedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
resp.LastUsedAt = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
if k.RevokedAt != nil {
|
||||||
|
s := k.RevokedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
resp.RevokedAt = &s
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all API keys.
|
||||||
|
// GET /keys
|
||||||
|
func (h *KeysHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
keys, err := h.authService.List(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
api.WriteInternalError(w, r, "Failed to list keys")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]KeyResponse, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
resp[i] = apiKeyToResponse(k)
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create generates a new API key.
|
||||||
|
// POST /keys
|
||||||
|
func (h *KeysHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CreateKeyRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
api.WriteBadRequest(w, r, "Invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
api.WriteBadRequest(w, r, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Scopes) == 0 {
|
||||||
|
api.WriteBadRequest(w, r, "scopes is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate scopes
|
||||||
|
scopes := auth.ScopesFromStrings(req.Scopes)
|
||||||
|
if !auth.ValidateScopes(scopes) {
|
||||||
|
api.WriteBadRequest(w, r, "invalid scope(s)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse expiration
|
||||||
|
expiresIn, err := auth.ParseExpiration(req.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
api.WriteBadRequest(w, r, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get creator from authenticated key
|
||||||
|
creator := "admin"
|
||||||
|
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil && apiKey.ID != "admin" {
|
||||||
|
creator = apiKey.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.authService.Create(r.Context(), auth.CreateKeyRequest{
|
||||||
|
Name: req.Name,
|
||||||
|
Scopes: scopes,
|
||||||
|
ProjectIDs: req.ProjectIDs,
|
||||||
|
ExpiresIn: expiresIn,
|
||||||
|
CreatedBy: creator,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
api.WriteInternalError(w, r, "Failed to create key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteCreated(w, r, CreateKeyResponse{
|
||||||
|
Key: apiKeyToResponse(result.Key),
|
||||||
|
Secret: result.Secret,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single API key.
|
||||||
|
// GET /keys/{id}
|
||||||
|
func (h *KeysHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
key, err := h.authService.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if err == auth.ErrKeyNotFound {
|
||||||
|
api.WriteNotFound(w, r, "Key not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.WriteInternalError(w, r, "Failed to get key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, apiKeyToResponse(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke marks an API key as revoked.
|
||||||
|
// DELETE /keys/{id}
|
||||||
|
func (h *KeysHandler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
if err := h.authService.Revoke(r.Context(), id); err != nil {
|
||||||
|
if err == auth.ErrKeyNotFound {
|
||||||
|
api.WriteNotFound(w, r, "Key not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.WriteInternalError(w, r, "Failed to revoke key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]string{
|
||||||
|
"status": "revoked",
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user