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:
jordan 2026-01-24 21:26:26 -07:00
parent 0960b17eb2
commit d2de49a591
11 changed files with 1390 additions and 70 deletions

2
.gitignore vendored
View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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,
})
}