Compare commits

...

10 Commits

Author SHA1 Message Date
7ca61eb712 - 2025-08-26 14:36:08 -04:00
7fa0c5dbfc - 2025-08-26 13:59:43 -04:00
2a9debd9b3 - 2025-08-26 13:57:40 -04:00
39e850f8ac - 2025-08-26 13:51:15 -04:00
e1c7e825af - 2025-08-26 13:06:43 -04:00
7ee9a9ac2c - 2025-08-25 21:44:54 -04:00
b39da8d233 audit logs 2025-08-25 21:42:41 -04:00
19364fcc76 - 2025-08-25 21:28:14 -04:00
efa2ee5b59 - 2025-08-25 20:40:26 -04:00
dc0cd946c2 claudefile update 2025-08-25 19:38:13 -04:00
28 changed files with 3496 additions and 1727 deletions

296
CLAUDE.md
View File

@ -6,6 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
This is an API Key Management Service (KMS) built with Go backend and React TypeScript frontend. The system manages API keys, user authentication, permissions, and provides both static tokens and user JWT tokens with hierarchical permission scopes.
**Key Technologies:**
- **Backend**: Go 1.23+ with Gin/Gorilla Mux, PostgreSQL, JWT tokens
- **Frontend**: React 19+ with TypeScript, Ant Design 5.27+
- **Infrastructure**: Podman/Docker Compose, Nginx, Redis (optional)
- **Security**: HMAC token signing, RBAC permissions, rate limiting
## Architecture
The project follows clean architecture principles with clear separation:
@ -16,13 +22,18 @@ internal/ - Go backend core logic
├── domain/ - Domain models and business logic
├── repository/ - Data access interfaces and PostgreSQL implementations
├── services/ - Business logic layer
├── handlers/ - HTTP request handlers
├── middleware/ - Authentication, logging, security middleware
├── config/ - Configuration management
├── handlers/ - HTTP request handlers (Gin-based)
├── middleware/ - Authentication, logging, security, CSRF middleware
├── config/ - Configuration management with validation
├── auth/ - JWT, OAuth2, SAML, header-based auth providers
├── cache/ - Redis caching layer (optional)
├── metrics/ - Prometheus metrics collection
└── database/ - Database connection and migrations
kms-frontend/ - React TypeScript frontend with Ant Design
migrations/ - PostgreSQL database migration files
test/ - Integration and E2E tests
test/ - Integration and E2E tests (both Go and bash)
docs/ - Comprehensive technical documentation
nginx/ - Nginx configuration for reverse proxy
```
## Development Commands
@ -30,13 +41,13 @@ test/ - Integration and E2E tests
### Go Backend
```bash
# Run the server locally
go run cmd/server/main.go
# Run the server locally (requires environment variables)
INTERNAL_HMAC_KEY=test-hmac-key JWT_SECRET=test-jwt-secret AUTH_SIGNING_KEY=test-signing-key go run cmd/server/main.go
# Build the binary
go build -o api-key-service ./cmd/server
# Run tests
# Run tests (uses kms_test database)
go test -v ./test/...
# Run tests with coverage
@ -56,7 +67,7 @@ go test -v ./test/ -run TestConcurrentRequests
# Navigate to frontend directory
cd kms-frontend
# Install dependencies
# Install dependencies (Node 24+, npm 11+)
npm install
# Start development server
@ -69,130 +80,285 @@ npm run build
npm test
```
### Docker & Development Environment
### Podman Compose & Development Environment
**CRITICAL**: This project uses `podman-compose`, not `docker-compose`.
```bash
# Start all services (PostgreSQL, API, Nginx, Frontend)
docker-compose up -d
podman-compose up -d
# Check service health
curl http://localhost/health
curl http://localhost:8081/health
# View logs
docker-compose logs -f
podman-compose logs -f
# View specific service logs
podman-compose logs -f api-service
podman-compose logs -f postgres
# Stop services
docker-compose down
podman-compose down
# Rebuild services after code changes
podman-compose up -d --build
```
## Database Setup
## Database Operations
The project uses PostgreSQL with automatic migrations. For local development:
**CRITICAL**: All database operations use `podman exec` commands. Never use direct `psql` commands.
### Database Access
```bash
# Create development database
psql -U postgres -c "CREATE DATABASE kms;"
# Access database shell (container name: kms-postgres)
podman exec -it kms-postgres psql -U postgres -d kms
# Create test database
psql -U postgres -c "CREATE DATABASE kms_test;"
# Run SQL commands via exec
podman exec -it kms-postgres psql -U postgres -c "SELECT * FROM applications LIMIT 5;"
# Check specific tables
podman exec -it kms-postgres psql -U postgres -d kms -c "\dt"
podman exec -it kms-postgres psql -U postgres -d kms -c "SELECT token_id, app_id, user_id FROM static_tokens LIMIT 5;"
# Apply migrations manually if needed
podman exec -it kms-postgres psql -U postgres -d kms -f /docker-entrypoint-initdb.d/001_initial_schema.up.sql
```
### Database Testing
```bash
# Create test database (if needed)
podman exec -it kms-postgres psql -U postgres -c "CREATE DATABASE kms_test;"
# Reset test database
podman exec -it kms-postgres psql -U postgres -c "DROP DATABASE IF EXISTS kms_test; CREATE DATABASE kms_test;"
# Check test data
podman exec -it kms-postgres psql -U postgres -d kms -c "SELECT * FROM applications WHERE name LIKE 'test-%';"
```
## Testing
The project uses podman-compose for all testing environments and database operations.
### End-to-End Testing
```bash
# Start test environment with podman-compose, guaranteeing that it updates with --build
podman-compose up -d --build
# Wait for services to be ready
sleep 10
# Run comprehensive E2E tests with curl
./test/e2e_test.sh
# Test against different server
# Test against specific server and user
BASE_URL=http://localhost:8080 USER_EMAIL=admin@example.com ./test/e2e_test.sh
# Clean up test environment
podman-compose down
```
### Test Environments
### Go Integration Tests
The service provides different test user contexts on different ports:
- Port 80: Regular user (`test@example.com`)
- Port 8081: Admin user (`admin@example.com`)
- Port 8082: Limited user (`limited@example.com`)
```bash
# Run Go integration tests (uses kms_test database)
go test -v ./test/...
# With podman-compose environment
podman-compose up -d
sleep 10
go test -v ./test/...
podman-compose down
```
### Test Environments & Ports
- **Port 8080**: Main API service
- **Port 8081**: Nginx proxy (main access point)
- **Port 3000**: React frontend (direct access)
- **Port 5432**: PostgreSQL database
- **Port 9090**: Metrics endpoint (if enabled)
The service provides different test user contexts:
- Regular user: `test@example.com`
- Admin user: `admin@example.com`
- Limited user: `limited@example.com`
## Key Configuration
Environment variables for configuration:
### Required Environment Variables
```bash
# Security (REQUIRED - minimum 32 characters each)
INTERNAL_HMAC_KEY=<secure-hmac-key-32-chars-min>
JWT_SECRET=<secure-jwt-secret-32-chars-min>
AUTH_SIGNING_KEY=<secure-auth-key-32-chars-min>
# Database
DB_HOST=localhost
DB_HOST=postgres # Use 'postgres' for containers, 'localhost' for local
DB_PORT=5432
DB_NAME=kms
DB_USER=postgres
DB_PASSWORD=postgres
DB_SSLMODE=disable
# Server
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# Authentication
AUTH_PROVIDER=header
AUTH_PROVIDER=header # or 'sso'
AUTH_HEADER_USER_EMAIL=X-User-Email
# Security
# Features
RATE_LIMIT_ENABLED=true
CACHE_ENABLED=false # Set to true to enable Redis
METRICS_ENABLED=true
SAML_ENABLED=false # Set to true for SAML auth
```
### Optional Configuration
```bash
# Rate Limiting
RATE_LIMIT_RPS=100
RATE_LIMIT_BURST=200
AUTH_RATE_LIMIT_RPS=5
AUTH_RATE_LIMIT_BURST=10
# Caching (Redis)
REDIS_ADDR=localhost:6379
REDIS_DB=0
# Security
MAX_AUTH_FAILURES=5
AUTH_FAILURE_WINDOW=15m
IP_BLOCK_DURATION=1h
# Logging
LOG_LEVEL=debug # debug, info, warn, error
LOG_FORMAT=json
```
## API Structure
The API provides:
- Health checks (`/health`, `/ready`)
- Authentication (`/api/login`, `/api/verify`, `/api/renew`)
- Application management (`/api/applications`)
- Token management (`/api/applications/{id}/tokens`)
- Permission hierarchies (repo.read, app.write, internal.admin, etc.)
### Core Endpoints
- **Health**: `/health`, `/ready`
- **Authentication**: `/api/login`, `/api/verify`, `/api/renew`
- **Applications**: `/api/applications` (CRUD operations)
- **Tokens**: `/api/applications/{id}/tokens` (Static token management)
- **Audit**: `/api/audit/events`, `/api/audit/events/:id`, `/api/audit/stats` (Audit log management)
- **Metrics**: `:9090/metrics` (Prometheus format, if enabled)
## Code Patterns
### Permission System
### Backend Patterns
- Repository pattern for data access with interfaces
- Service layer for business logic with dependency injection
- Middleware chain for authentication, logging, security
- Structured error handling with custom error types
- Comprehensive logging with zap structured logger
### Frontend Patterns
- React functional components with TypeScript
- Ant Design component library
- Context API for authentication state
- Axios for API communication
- React Router for navigation
## Permission System
Hierarchical permission scopes:
- `internal.*` - System operations
Hierarchical permission scopes (parent permissions include child permissions):
- `internal.*` - System operations (highest level)
- `app.*` - Application management
- `token.*` - Token operations
- `repo.*` - Repository access (example domain)
- `permission.*` - Permission management
Parent permissions inherit child permissions (e.g., `repo` includes `repo.read` and `repo.write`).
Example: `repo` permission includes `repo.read` and `repo.write`.
## Database Schema
Key tables:
### Key Tables
- `applications` - Application definitions with HMAC keys
- `static_tokens` - Static API tokens
- `static_tokens` - Static API tokens with prefixes
- `available_permissions` - Permission catalog
- `granted_permissions` - Token-permission relationships
- `user_sessions` - User session tracking
- `user_sessions` - User session tracking with JWT
- `audit_events` - Comprehensive audit logging with fields:
- `id`, `type`, `severity`, `status`, `timestamp`
- `actor_id`, `actor_type`, `actor_ip`, `user_agent`
- `resource_id`, `resource_type`, `action`, `description`
- `details` (JSON), `request_id`, `session_id`
### Migration System
- Auto-runs on startup
- Located in `/migrations/`
- Uses `golang-migrate/migrate/v4`
- Supports both up and down migrations
## Code Patterns & Architecture
### Backend Patterns
- **Repository Pattern**: Data access via interfaces (`internal/repository/interfaces.go`)
- **Dependency Injection**: Services receive dependencies via constructors
- **Middleware Chain**: Security, auth, logging, rate limiting
- **Structured Errors**: Custom error types with proper HTTP status codes
- **Structured Logging**: Zap logger with JSON output
- **Configuration Provider**: Interface-based config with validation
- **Multiple Auth Providers**: Header, OAuth2, SAML support
### Frontend Patterns
- **React 19** with TypeScript
- **Ant Design 5.27+** component library
- **Context API** for authentication state (`AuthContext.tsx`)
- **Axios** for API communication with interceptors
- **React Router 7+** for navigation
- **Component Structure**: Organized by feature (Applications, Tokens, Users, Audit)
- **Audit Integration**: Real-time audit log viewing with filtering, statistics, and timeline views
### Security Patterns
- **HMAC Token Signing**: All tokens cryptographically signed
- **JWT with Rotation**: User tokens with refresh capability
- **Rate Limiting**: Per-endpoint and per-user limits
- **CSRF Protection**: Token-based CSRF protection
- **Audit Logging**: All operations logged with user attribution
- **Input Validation**: Comprehensive validation at all layers
### Audit System Architecture
- **Handler**: `internal/handlers/audit.go` - HTTP endpoints for audit data
- **Logger**: `internal/audit/audit.go` - Core audit logging functionality
- **Repository**: `internal/repository/postgres/audit_repository.go` - Data persistence
- **Frontend**: `kms-frontend/src/components/Audit.tsx` - Real-time audit viewing
- **API Service**: `kms-frontend/src/services/apiService.ts` - Frontend-backend integration
- **Event Types**: Hierarchical (e.g., `auth.login`, `app.created`, `token.validated`)
- **Filtering**: Support for date ranges, event types, statuses, users, resource types
- **Statistics**: Aggregated metrics by type, severity, status, and time
## Development Notes
- The backend uses interfaces throughout for testability and dependency injection
- Database migrations run automatically on startup
- Rate limiting is configurable per endpoint
- All operations include audit logging with user attribution
- Token security uses HMAC signing with rotation capabilities
- Frontend uses protected routes with authentication context
- E2E tests provide comprehensive API coverage with curl
### Critical Information
- **Go Version**: Requires Go 1.23+ (currently using 1.24.4)
- **Node Version**: Requires Node 24+ and npm 11+
- **Database**: Auto-migrations run on startup
- **Container Names**: Use `kms-postgres`, `kms-api-service`, `kms-frontend`, `kms-nginx`
- **Default Ports**: API:8080, Nginx:8081, Frontend:3000, DB:5432, Metrics:9090
- **Test Database**: `kms_test` (separate from `kms`)
### Important Files
- `internal/config/config.go` - Complete configuration management
- `docker-compose.yml` - Service definitions and environment variables
- `test/e2e_test.sh` - Comprehensive curl-based E2E tests
- `test/README.md` - Detailed testing guide
- `docs/` - Technical documentation (Architecture, Security, API docs)
### Development Workflow
1. Always use `podman-compose` (not `docker-compose`)
2. Database operations via `podman exec` only
3. Required environment variables for local dev (HMAC, JWT, AUTH keys)
4. Run tests after changes: `go test -v ./test/...`
5. Use E2E tests to verify end-to-end functionality
6. Frontend dev server connects to containerized backend
### Build & Deployment Notes
- **Cache Issues**: When code changes don't appear, use `podman-compose build --no-cache`
- **Route Registration**: New API routes require full rebuild to appear in Gin debug logs
- **Error Handlers**: Use `HandleInternalError`, `HandleValidationError`, `HandleAuthenticationError`
- **API Integration**: Frontend components should use real API calls, not mock data
- **Field Mapping**: Ensure frontend matches backend field names (e.g., `actor_id` vs `user_id`)
### Security Considerations
- Never commit secrets to repository
- All tokens use HMAC signing with secure keys
- Rate limiting prevents abuse
- Comprehensive audit logging for compliance
- Input validation at all layers
- CORS and security headers properly configured

View File

@ -44,9 +44,13 @@ COPY --from=builder /app/api-key-service /app/api-key-service
# Copy migration files
COPY --from=builder /app/migrations /app/migrations
# Copy template files
COPY --from=builder /app/templates /app/templates
# Change ownership to non-root user
RUN chown -R appuser:appgroup /app && \
chmod -R 755 /app/migrations
chmod -R 755 /app/migrations && \
chmod -R 755 /app/templates
# Switch to non-root user
USER appuser

View File

@ -12,8 +12,10 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/config"
"github.com/kms/api-key-service/internal/database"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/handlers"
"github.com/kms/api-key-service/internal/metrics"
"github.com/kms/api-key-service/internal/middleware"
@ -63,9 +65,13 @@ func main() {
tokenRepo := postgres.NewStaticTokenRepository(db)
permRepo := postgres.NewPermissionRepository(db)
grantRepo := postgres.NewGrantedPermissionRepository(db)
auditRepo := postgres.NewAuditRepository(db)
// Initialize audit logger
auditLogger := audit.NewAuditLogger(cfg, logger, auditRepo)
// Initialize services
appService := services.NewApplicationService(appRepo, logger)
appService := services.NewApplicationService(appRepo, auditRepo, logger)
tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, cfg.GetString("INTERNAL_HMAC_KEY"), cfg, logger)
authService := services.NewAuthenticationService(cfg, logger, permRepo)
@ -74,9 +80,10 @@ func main() {
appHandler := handlers.NewApplicationHandler(appService, authService, logger)
tokenHandler := handlers.NewTokenHandler(tokenService, authService, logger)
authHandler := handlers.NewAuthHandler(authService, tokenService, cfg, logger)
auditHandler := handlers.NewAuditHandler(auditLogger, authService, logger)
// Set up router
router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler)
router := setupRouter(cfg, logger, healthHandler, appHandler, tokenHandler, authHandler, auditHandler)
// Create HTTP server
srv := &http.Server{
@ -150,7 +157,7 @@ func initLogger(cfg config.ConfigProvider) *zap.Logger {
return logger
}
func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *handlers.HealthHandler, appHandler *handlers.ApplicationHandler, tokenHandler *handlers.TokenHandler, authHandler *handlers.AuthHandler) *gin.Engine {
func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *handlers.HealthHandler, appHandler *handlers.ApplicationHandler, tokenHandler *handlers.TokenHandler, authHandler *handlers.AuthHandler, auditHandler *handlers.AuditHandler) *gin.Engine {
// Set Gin mode based on environment
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
@ -178,7 +185,8 @@ func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *h
api := router.Group("/api")
{
// Authentication endpoints (no prior auth required)
api.POST("/login", authHandler.Login)
api.GET("/login", authHandler.Login) // HTML page for browser access
api.POST("/login", authHandler.Login) // JSON API for programmatic access
api.POST("/verify", authHandler.Verify)
api.POST("/renew", authHandler.Renew)
@ -198,6 +206,11 @@ func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *h
protected.POST("/applications/:id/tokens", tokenHandler.Create)
protected.DELETE("/tokens/:id", tokenHandler.Delete)
// Audit management
protected.GET("/audit/events", auditHandler.ListEvents)
protected.GET("/audit/events/:id", auditHandler.GetEvent)
protected.GET("/audit/stats", auditHandler.GetStats)
// Documentation endpoint
protected.GET("/docs", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@ -222,6 +235,11 @@ func setupRouter(cfg config.ConfigProvider, logger *zap.Logger, healthHandler *h
"POST /api/applications/:id/tokens",
"DELETE /api/tokens/:id",
},
"audit": []string{
"GET /api/audit/events",
"GET /api/audit/events/:id",
"GET /api/audit/stats",
},
},
})
})
@ -269,9 +287,51 @@ func initializeBootstrapData(ctx context.Context, appService services.Applicatio
logger.Info("Creating internal application for bootstrap", zap.String("app_id", internalAppID))
// This will be implemented when we create the services
// For now, we'll just log that we need to do this
logger.Warn("Bootstrap data initialization not yet implemented - will be added when services are ready")
// Create internal application for system operations
internalAppReq := &domain.CreateApplicationRequest{
AppID: internalAppID,
AppLink: "https://kms.internal/system",
Type: []domain.ApplicationType{domain.ApplicationTypeStatic, domain.ApplicationTypeUser},
CallbackURL: "https://kms.internal/callback",
TokenPrefix: "KMS",
TokenRenewalDuration: domain.Duration{Duration: 365 * 24 * time.Hour}, // 1 year
MaxTokenDuration: domain.Duration{Duration: 365 * 24 * time.Hour}, // 1 year
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "KMS System",
Owner: "system@kms.internal",
},
}
app, err := appService.Create(ctx, internalAppReq, "system")
if err != nil {
logger.Error("Failed to create internal application", zap.Error(err))
return err
}
logger.Info("Internal application created successfully",
zap.String("app_id", app.AppID),
zap.String("hmac_key", app.HMACKey))
// Create a static token for internal system operations if needed
internalTokenReq := &domain.CreateStaticTokenRequest{
AppID: internalAppID,
Owner: domain.Owner{
Type: domain.OwnerTypeTeam,
Name: "KMS System Token",
Owner: "system@kms.internal",
},
Permissions: []string{"internal.*", "app.*", "token.*", "audit.*"},
}
token, err := tokenService.CreateStaticToken(ctx, internalTokenReq, "system")
if err != nil {
logger.Warn("Failed to create internal system token, continuing...", zap.Error(err))
} else {
logger.Info("Internal system token created successfully",
zap.String("token_id", token.ID.String()))
}
logger.Info("Bootstrap data initialization completed successfully")
return nil
}

View File

@ -1,852 +0,0 @@
# KMS Database Schema Documentation
## Table of Contents
1. [Schema Overview](#schema-overview)
2. [Entity Relationship Diagram](#entity-relationship-diagram)
3. [Table Definitions](#table-definitions)
4. [Relationships and Constraints](#relationships-and-constraints)
5. [Indexes and Performance](#indexes-and-performance)
6. [Security Considerations](#security-considerations)
7. [Migration Strategy](#migration-strategy)
8. [Query Patterns](#query-patterns)
---
## Schema Overview
The KMS database schema is designed around core entities that manage applications, tokens, permissions, and user sessions. The schema follows PostgreSQL best practices with proper normalization, constraints, and indexing strategies.
### Core Entities
- **Applications**: Central configuration for API key management
- **Static Tokens**: Long-lived API tokens with HMAC signatures
- **User Sessions**: JWT token tracking with metadata
- **Available Permissions**: Hierarchical permission catalog
- **Granted Permissions**: Token-permission relationships
- **Audit Logs**: Complete audit trail of all operations
### Design Principles
- **Normalized Design**: Reduces data redundancy
- **Referential Integrity**: Foreign key constraints ensure consistency
- **Audit Trail**: Complete history of all operations
- **Performance Optimized**: Strategic indexing for common queries
- **Security First**: Sensitive data protection and access controls
---
## Entity Relationship Diagram
```mermaid
erDiagram
%% Core Application Entity
applications {
string app_id PK "Application identifier"
string app_link "Application URL"
text[] type "static|user application types"
string callback_url "OAuth2 callback URL"
string hmac_key "HMAC signing key"
string token_prefix "Custom token prefix"
bigint token_renewal_duration "Token renewal window (ns)"
bigint max_token_duration "Max token lifetime (ns)"
string owner_type "individual|team"
string owner_name "Owner display name"
string owner_owner "Owner identifier"
timestamp created_at
timestamp updated_at
}
%% Static Token Management
static_tokens {
uuid id PK
string app_id FK "References applications.app_id"
string owner_type "individual|team"
string owner_name "Token owner name"
string owner_owner "Token owner identifier"
string key_hash "BCrypt hashed token (cost 14)"
string type "Always 'hmac'"
timestamp created_at
timestamp updated_at
}
%% User Session Tracking
user_sessions {
uuid id PK
string user_id "User identifier"
string app_id FK "References applications.app_id"
string session_token "Hashed session identifier"
text permissions "JSON array of permissions"
timestamp expires_at "Session expiration"
timestamp max_valid_at "Maximum validity window"
string provider "header|jwt|oauth2|saml"
json metadata "Provider-specific data"
boolean active "Session status"
timestamp created_at
timestamp updated_at
timestamp last_used_at
}
%% Permission Catalog
available_permissions {
uuid id PK
string scope UK "Unique permission scope"
string name "Human-readable name"
text description "Permission description"
string category "Permission category"
string parent_scope FK "References available_permissions.scope"
boolean is_system "System permission flag"
timestamp created_at
string created_by "Creator identifier"
timestamp updated_at
string updated_by "Last updater"
}
%% Token-Permission Relationships
granted_permissions {
uuid id PK
string token_type "static|user"
uuid token_id FK "References static_tokens.id"
uuid permission_id FK "References available_permissions.id"
string scope "Denormalized permission scope"
timestamp created_at
string created_by "Grant creator"
boolean revoked "Permission revocation status"
timestamp revoked_at "Revocation timestamp"
string revoked_by "Revoker identifier"
}
%% Audit Trail
audit_logs {
uuid id PK
timestamp timestamp "Event timestamp"
string user_id "Acting user"
string action "Action performed"
string resource_type "Resource type affected"
string resource_id "Resource identifier"
json old_values "Previous values"
json new_values "New values"
string ip_address "Client IP"
string user_agent "Client user agent"
json metadata "Additional context"
}
%% Relationships
applications ||--o{ static_tokens : "app_id"
applications ||--o{ user_sessions : "app_id"
available_permissions ||--o{ available_permissions : "parent_scope"
available_permissions ||--o{ granted_permissions : "permission_id"
static_tokens ||--o{ granted_permissions : "token_id"
%% Indexes and Constraints
applications {
index idx_applications_owner_type "owner_type"
index idx_applications_created_at "created_at"
check owner_type_valid "owner_type IN ('individual', 'team')"
check type_not_empty "array_length(type, 1) > 0"
}
static_tokens {
index idx_static_tokens_app_id "app_id"
index idx_static_tokens_key_hash "key_hash"
unique key_hash_unique "key_hash"
check type_hmac "type = 'hmac'"
}
user_sessions {
index idx_user_sessions_user_id "user_id"
index idx_user_sessions_app_id "app_id"
index idx_user_sessions_token "session_token"
index idx_user_sessions_expires_at "expires_at"
index idx_user_sessions_active "active"
}
available_permissions {
index idx_available_permissions_scope "scope"
index idx_available_permissions_category "category"
index idx_available_permissions_parent_scope "parent_scope"
index idx_available_permissions_is_system "is_system"
}
granted_permissions {
index idx_granted_permissions_token "token_type, token_id"
index idx_granted_permissions_permission_id "permission_id"
index idx_granted_permissions_scope "scope"
index idx_granted_permissions_revoked "revoked"
unique token_permission_unique "token_type, token_id, permission_id"
}
```
---
## Table Definitions
### Applications Table
The central configuration table for all applications in the system.
```sql
CREATE TABLE applications (
app_id VARCHAR(100) PRIMARY KEY,
app_link TEXT NOT NULL,
type TEXT[] NOT NULL DEFAULT '{}',
callback_url TEXT,
hmac_key TEXT NOT NULL,
token_prefix VARCHAR(10),
token_renewal_duration BIGINT NOT NULL DEFAULT 3600000000000, -- 1 hour in nanoseconds
max_token_duration BIGINT NOT NULL DEFAULT 86400000000000, -- 24 hours in nanoseconds
owner_type VARCHAR(20) NOT NULL CHECK (owner_type IN ('individual', 'team')),
owner_name VARCHAR(255) NOT NULL,
owner_owner VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT app_id_format CHECK (app_id ~ '^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$'),
CONSTRAINT type_not_empty CHECK (array_length(type, 1) > 0),
CONSTRAINT token_prefix_format CHECK (token_prefix IS NULL OR token_prefix ~ '^[A-Z]{2,4}$'),
CONSTRAINT valid_durations CHECK (
token_renewal_duration > 0 AND
max_token_duration > 0 AND
max_token_duration > token_renewal_duration
)
);
```
#### Field Descriptions
- **`app_id`**: Unique application identifier, must follow naming conventions
- **`app_link`**: URL to the application for reference
- **`type`**: Array of supported token types (`static`, `user`)
- **`callback_url`**: OAuth2/SAML callback URL for authentication flows
- **`hmac_key`**: HMAC signing key for static token validation
- **`token_prefix`**: Custom prefix for generated tokens (2-4 uppercase letters)
- **`token_renewal_duration`**: How long tokens can be renewed (nanoseconds)
- **`max_token_duration`**: Maximum token lifetime (nanoseconds)
- **`owner_type`**: Individual or team ownership
- **`owner_name`**: Display name of the owner
- **`owner_owner`**: Identifier of the owner (email for individual, team ID for team)
### Static Tokens Table
Long-lived API tokens with HMAC-based authentication.
```sql
CREATE TABLE static_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id VARCHAR(100) NOT NULL REFERENCES applications(app_id) ON DELETE CASCADE,
owner_type VARCHAR(20) NOT NULL CHECK (owner_type IN ('individual', 'team')),
owner_name VARCHAR(255) NOT NULL,
owner_owner VARCHAR(255) NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
type VARCHAR(10) NOT NULL DEFAULT 'hmac' CHECK (type = 'hmac'),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
```
#### Security Features
- **`key_hash`**: BCrypt hash of the actual token (cost 14)
- **Unique constraint**: Prevents token duplication
- **Cascade deletion**: Tokens deleted when application is removed
- **Owner tracking**: Links tokens to their creators
### User Sessions Table
JWT token session tracking with comprehensive metadata.
```sql
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
app_id VARCHAR(100) NOT NULL REFERENCES applications(app_id) ON DELETE CASCADE,
session_token VARCHAR(255) NOT NULL,
permissions TEXT NOT NULL DEFAULT '[]', -- JSON array
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
max_valid_at TIMESTAMP WITH TIME ZONE NOT NULL,
provider VARCHAR(20) NOT NULL CHECK (provider IN ('header', 'jwt', 'oauth2', 'saml')),
metadata JSONB DEFAULT '{}',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE,
-- Constraints
CONSTRAINT valid_session_times CHECK (max_valid_at >= expires_at),
CONSTRAINT valid_permissions CHECK (permissions::json IS NOT NULL)
);
```
#### Session Management Features
- **Session tracking**: Complete session lifecycle management
- **Multi-provider support**: Tracks authentication method
- **Permission storage**: JSON array of granted permissions
- **Activity tracking**: Last used timestamp for session cleanup
- **Flexible metadata**: Provider-specific data storage
### Available Permissions Table
Hierarchical permission catalog with system and custom permissions.
```sql
CREATE TABLE available_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scope VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT,
category VARCHAR(100) NOT NULL DEFAULT 'custom',
parent_scope VARCHAR(255) REFERENCES available_permissions(scope) ON DELETE SET NULL,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_by VARCHAR(255) NOT NULL,
-- Constraints
CONSTRAINT scope_format CHECK (scope ~ '^[a-zA-Z][a-zA-Z0-9._]*[a-zA-Z0-9]$'),
CONSTRAINT no_self_reference CHECK (scope != parent_scope)
);
```
#### Permission Hierarchy Features
- **Hierarchical structure**: Parent-child permission relationships
- **System permissions**: Built-in permissions that cannot be deleted
- **Flexible scoping**: Dot-notation permission scopes
- **Audit tracking**: Creation and modification history
### Granted Permissions Table
Token-permission relationship management with revocation support.
```sql
CREATE TABLE granted_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_type VARCHAR(10) NOT NULL CHECK (token_type IN ('static', 'user')),
token_id UUID NOT NULL,
permission_id UUID NOT NULL REFERENCES available_permissions(id) ON DELETE CASCADE,
scope VARCHAR(255) NOT NULL, -- Denormalized for performance
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
created_by VARCHAR(255) NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT false,
revoked_at TIMESTAMP WITH TIME ZONE,
revoked_by VARCHAR(255),
-- Constraints
CONSTRAINT unique_token_permission UNIQUE (token_type, token_id, permission_id),
CONSTRAINT valid_revocation CHECK (
(revoked = false AND revoked_at IS NULL AND revoked_by IS NULL) OR
(revoked = true AND revoked_at IS NOT NULL AND revoked_by IS NOT NULL)
)
);
```
#### Permission Grant Features
- **Multi-token support**: Works with both static and user tokens
- **Denormalized scope**: Performance optimization for common queries
- **Revocation tracking**: Complete audit trail of permission changes
- **Referential integrity**: Maintains consistency with permission catalog
### Audit Logs Table
Complete audit trail of all system operations.
```sql
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
user_id VARCHAR(255) NOT NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id VARCHAR(255),
old_values JSONB,
new_values JSONB,
ip_address INET,
user_agent TEXT,
metadata JSONB DEFAULT '{}',
-- Constraints
CONSTRAINT valid_action CHECK (action ~ '^[a-z][a-z_]*[a-z]$'),
CONSTRAINT valid_resource_type CHECK (resource_type ~ '^[a-z][a-z_]*[a-z]$')
);
```
#### Audit Features
- **Complete coverage**: All operations logged
- **Before/after values**: Full change tracking
- **Client context**: IP address and user agent
- **Flexible metadata**: Additional context storage
- **Time-series data**: Ordered by timestamp for analysis
---
## Relationships and Constraints
### Primary Relationships
#### **Application → Static Tokens (1:N)**
```sql
ALTER TABLE static_tokens
ADD CONSTRAINT fk_static_tokens_app_id
FOREIGN KEY (app_id) REFERENCES applications(app_id) ON DELETE CASCADE;
```
#### **Application → User Sessions (1:N)**
```sql
ALTER TABLE user_sessions
ADD CONSTRAINT fk_user_sessions_app_id
FOREIGN KEY (app_id) REFERENCES applications(app_id) ON DELETE CASCADE;
```
#### **Available Permissions → Self (Hierarchy)**
```sql
ALTER TABLE available_permissions
ADD CONSTRAINT fk_available_permissions_parent
FOREIGN KEY (parent_scope) REFERENCES available_permissions(scope) ON DELETE SET NULL;
```
#### **Granted Permissions → Available Permissions (N:1)**
```sql
ALTER TABLE granted_permissions
ADD CONSTRAINT fk_granted_permissions_permission
FOREIGN KEY (permission_id) REFERENCES available_permissions(id) ON DELETE CASCADE;
```
### Data Integrity Constraints
#### **Check Constraints**
```sql
-- Application ID format validation
ALTER TABLE applications
ADD CONSTRAINT app_id_format
CHECK (app_id ~ '^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$');
-- Owner type validation
ALTER TABLE applications
ADD CONSTRAINT owner_type_valid
CHECK (owner_type IN ('individual', 'team'));
-- Token type validation
ALTER TABLE static_tokens
ADD CONSTRAINT type_hmac
CHECK (type = 'hmac');
-- Permission scope format
ALTER TABLE available_permissions
ADD CONSTRAINT scope_format
CHECK (scope ~ '^[a-zA-Z][a-zA-Z0-9._]*[a-zA-Z0-9]$');
-- Session time validation
ALTER TABLE user_sessions
ADD CONSTRAINT valid_session_times
CHECK (max_valid_at >= expires_at);
```
#### **Unique Constraints**
```sql
-- Unique token hashes
ALTER TABLE static_tokens
ADD CONSTRAINT key_hash_unique UNIQUE (key_hash);
-- Unique permission scopes
ALTER TABLE available_permissions
ADD CONSTRAINT scope_unique UNIQUE (scope);
-- Unique token-permission relationships
ALTER TABLE granted_permissions
ADD CONSTRAINT unique_token_permission
UNIQUE (token_type, token_id, permission_id);
```
---
## Indexes and Performance
### Primary Indexes
#### **Applications Table**
```sql
-- Primary key index (automatic)
CREATE INDEX idx_applications_owner_type ON applications(owner_type);
CREATE INDEX idx_applications_created_at ON applications(created_at);
CREATE INDEX idx_applications_owner_owner ON applications(owner_owner);
```
#### **Static Tokens Table**
```sql
-- Foreign key and lookup indexes
CREATE INDEX idx_static_tokens_app_id ON static_tokens(app_id);
CREATE INDEX idx_static_tokens_key_hash ON static_tokens(key_hash); -- For token verification
CREATE INDEX idx_static_tokens_created_at ON static_tokens(created_at);
CREATE INDEX idx_static_tokens_owner_owner ON static_tokens(owner_owner);
```
#### **User Sessions Table**
```sql
-- Session lookup indexes
CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_app_id ON user_sessions(app_id);
CREATE INDEX idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at);
CREATE INDEX idx_user_sessions_active ON user_sessions(active);
-- Composite index for active session lookup
CREATE INDEX idx_user_sessions_active_lookup
ON user_sessions(user_id, app_id, active) WHERE active = true;
-- Cleanup index for expired sessions
CREATE INDEX idx_user_sessions_cleanup
ON user_sessions(expires_at) WHERE active = true;
```
#### **Available Permissions Table**
```sql
-- Permission lookup indexes
CREATE INDEX idx_available_permissions_scope ON available_permissions(scope);
CREATE INDEX idx_available_permissions_category ON available_permissions(category);
CREATE INDEX idx_available_permissions_parent_scope ON available_permissions(parent_scope);
CREATE INDEX idx_available_permissions_is_system ON available_permissions(is_system);
-- Hierarchy traversal index
CREATE INDEX idx_available_permissions_hierarchy
ON available_permissions(parent_scope, scope);
```
#### **Granted Permissions Table**
```sql
-- Token permission lookup
CREATE INDEX idx_granted_permissions_token ON granted_permissions(token_type, token_id);
CREATE INDEX idx_granted_permissions_permission_id ON granted_permissions(permission_id);
CREATE INDEX idx_granted_permissions_scope ON granted_permissions(scope);
CREATE INDEX idx_granted_permissions_revoked ON granted_permissions(revoked);
-- Active permissions index
CREATE INDEX idx_granted_permissions_active
ON granted_permissions(token_type, token_id, scope) WHERE revoked = false;
-- Permission cleanup index
CREATE INDEX idx_granted_permissions_revoked_at ON granted_permissions(revoked_at);
```
#### **Audit Logs Table**
```sql
-- Audit query indexes
CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
-- Time-series partitioning preparation
CREATE INDEX idx_audit_logs_monthly
ON audit_logs(date_trunc('month', timestamp), timestamp);
```
### Performance Optimization
#### **Partial Indexes**
```sql
-- Index only active sessions
CREATE INDEX idx_active_sessions
ON user_sessions(user_id, app_id) WHERE active = true;
-- Index only non-revoked permissions
CREATE INDEX idx_active_permissions
ON granted_permissions(token_id, scope) WHERE revoked = false;
-- Index only system permissions
CREATE INDEX idx_system_permissions
ON available_permissions(scope, parent_scope) WHERE is_system = true;
```
#### **Composite Indexes**
```sql
-- Application ownership queries
CREATE INDEX idx_applications_ownership
ON applications(owner_type, owner_owner, created_at);
-- Token verification queries
CREATE INDEX idx_token_verification
ON static_tokens(app_id, key_hash);
-- Permission evaluation queries
CREATE INDEX idx_permission_evaluation
ON granted_permissions(token_type, token_id, permission_id, revoked);
```
---
## Security Considerations
### Data Protection
#### **Sensitive Data Handling**
```sql
-- HMAC keys should be encrypted at application level
-- Token hashes use BCrypt with cost 14
-- Audit logs contain no sensitive data in plaintext
-- Row-level security (RLS) for multi-tenant isolation
ALTER TABLE applications ENABLE ROW LEVEL SECURITY;
ALTER TABLE static_tokens ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
-- Example RLS policy for application isolation
CREATE POLICY app_owner_policy ON applications
FOR ALL TO app_user
USING (owner_owner = current_setting('app.user_id'));
```
#### **Access Control**
```sql
-- Database roles for different access patterns
CREATE ROLE kms_api_service;
CREATE ROLE kms_readonly;
CREATE ROLE kms_admin;
-- Grant appropriate permissions
GRANT SELECT, INSERT, UPDATE, DELETE ON applications TO kms_api_service;
GRANT SELECT, INSERT, UPDATE, DELETE ON static_tokens TO kms_api_service;
GRANT SELECT, INSERT, UPDATE, DELETE ON user_sessions TO kms_api_service;
GRANT SELECT, INSERT, UPDATE, DELETE ON granted_permissions TO kms_api_service;
GRANT SELECT, INSERT ON audit_logs TO kms_api_service;
-- Read-only access for reporting
GRANT SELECT ON ALL TABLES IN SCHEMA public TO kms_readonly;
-- Admin access for maintenance
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO kms_admin;
```
### Audit and Compliance
#### **Audit Triggers**
```sql
-- Automatic audit logging trigger
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_logs (
user_id, action, resource_type, resource_id,
old_values, new_values, ip_address
) VALUES (
current_setting('app.user_id', true),
TG_OP,
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id)::text,
CASE WHEN TG_OP = 'DELETE' THEN row_to_json(OLD) ELSE NULL END,
CASE WHEN TG_OP = 'INSERT' THEN row_to_json(NEW)
WHEN TG_OP = 'UPDATE' THEN row_to_json(NEW)
ELSE NULL END,
inet_client_addr()
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Apply audit trigger to sensitive tables
CREATE TRIGGER audit_applications
AFTER INSERT OR UPDATE OR DELETE ON applications
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
CREATE TRIGGER audit_static_tokens
AFTER INSERT OR UPDATE OR DELETE ON static_tokens
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
CREATE TRIGGER audit_granted_permissions
AFTER INSERT OR UPDATE OR DELETE ON granted_permissions
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
```
---
## Migration Strategy
### Migration Framework
The KMS uses a custom Go migration system with the following structure:
```
migrations/
├── 001_initial_schema.up.sql
├── 001_initial_schema.down.sql
├── 002_user_sessions.up.sql
├── 002_user_sessions.down.sql
├── 003_add_token_prefix.up.sql
└── 003_add_token_prefix.down.sql
```
#### **Migration Management**
```go
// File: internal/database/migrations.go
type Migration struct {
Version int
Name string
UpScript string
DownScript string
AppliedAt time.Time
}
func RunMigrations(db *sql.DB, migrationsPath string) error {
// Create migration tracking table
createMigrationTable(db)
// Get applied migrations
applied, err := getAppliedMigrations(db)
if err != nil {
return err
}
// Run pending migrations
return runPendingMigrations(db, migrationsPath, applied)
}
```
### Schema Versioning
#### **Version Control**
- **Sequential numbering**: 001, 002, 003...
- **Descriptive names**: Clear migration purpose
- **Rollback support**: Down scripts for every migration
- **Atomic operations**: Each migration in a transaction
#### **Migration Best Practices**
```sql
-- Always start with BEGIN; and end with COMMIT;
BEGIN;
-- Add new columns with default values
ALTER TABLE applications
ADD COLUMN token_prefix VARCHAR(10) DEFAULT NULL;
-- Add constraints after data migration
ALTER TABLE applications
ADD CONSTRAINT token_prefix_format
CHECK (token_prefix IS NULL OR token_prefix ~ '^[A-Z]{2,4}$');
-- Create indexes concurrently (outside transaction)
COMMIT;
CREATE INDEX CONCURRENTLY idx_applications_token_prefix
ON applications(token_prefix) WHERE token_prefix IS NOT NULL;
```
---
## Query Patterns
### Common Query Patterns
#### **Application Queries**
```sql
-- Get application with ownership check
SELECT a.* FROM applications a
WHERE a.app_id = $1
AND (a.owner_owner = $2 OR $2 = 'admin@example.com');
-- List applications for user with pagination
SELECT a.app_id, a.app_link, a.owner_name, a.created_at
FROM applications a
WHERE a.owner_owner = $1
ORDER BY a.created_at DESC
LIMIT $2 OFFSET $3;
```
#### **Token Verification Queries**
```sql
-- Verify static token
SELECT st.id, st.app_id, st.key_hash
FROM static_tokens st
WHERE st.app_id = $1;
-- Get token permissions
SELECT ap.scope
FROM granted_permissions gp
JOIN available_permissions ap ON gp.permission_id = ap.id
WHERE gp.token_type = 'static'
AND gp.token_id = $1
AND gp.revoked = false;
```
#### **Session Management Queries**
```sql
-- Get active user session
SELECT us.* FROM user_sessions us
WHERE us.user_id = $1
AND us.app_id = $2
AND us.active = true
AND us.expires_at > NOW()
ORDER BY us.created_at DESC
LIMIT 1;
-- Clean up expired sessions
UPDATE user_sessions
SET active = false, updated_at = NOW()
WHERE active = true
AND expires_at < NOW();
```
#### **Permission Evaluation Queries**
```sql
-- Check user permission
WITH user_permissions AS (
SELECT DISTINCT ap.scope
FROM user_sessions us
JOIN granted_permissions gp ON gp.token_type = 'user'
JOIN available_permissions ap ON gp.permission_id = ap.id
WHERE us.user_id = $1
AND us.app_id = $2
AND us.active = true
AND us.expires_at > NOW()
AND gp.revoked = false
)
SELECT EXISTS(
SELECT 1 FROM user_permissions
WHERE scope = $3 OR scope = split_part($3, '.', 1)
);
-- Get permission hierarchy
WITH RECURSIVE permission_tree AS (
-- Base case: root permissions
SELECT id, scope, name, parent_scope, 0 as level
FROM available_permissions
WHERE parent_scope IS NULL
UNION ALL
-- Recursive case: child permissions
SELECT ap.id, ap.scope, ap.name, ap.parent_scope, pt.level + 1
FROM available_permissions ap
JOIN permission_tree pt ON ap.parent_scope = pt.scope
)
SELECT * FROM permission_tree ORDER BY level, scope;
```
### Performance Tuning
#### **Query Optimization**
```sql
-- Use EXPLAIN ANALYZE for query planning
EXPLAIN (ANALYZE, BUFFERS)
SELECT st.id FROM static_tokens st
WHERE st.app_id = 'test-app' AND st.key_hash = 'hash123';
-- Optimize with covering indexes
CREATE INDEX idx_static_tokens_covering
ON static_tokens(app_id, key_hash)
INCLUDE (id, created_at);
-- Use partial indexes for frequent filters
CREATE INDEX idx_active_sessions_partial
ON user_sessions(user_id, app_id, expires_at)
WHERE active = true;
```
#### **Connection Pooling Configuration**
```yaml
database:
host: postgres
port: 5432
name: kms
user: kms_api_service
max_open_connections: 25
max_idle_connections: 5
connection_max_lifetime: 300s
connection_max_idle_time: 60s
```
This database schema documentation provides comprehensive coverage of the KMS data model, suitable for developers, database administrators, and system architects who need to understand, maintain, or extend the database layer.

View File

@ -1,498 +0,0 @@
# Security Audit Report - KMS API Key Management Service
**Date**: 2025-08-23
**Auditor**: Claude Code Security Analysis
**Version**: v1.0.0
**Scope**: Complete codebase analysis including Go backend, React frontend, database schema, and Docker configuration
## Executive Summary
This comprehensive security audit identified **17 critical security vulnerabilities**, **12 high-risk issues**, and **8 medium-risk concerns** across the KMS (Key Management Service) codebase. The system demonstrates good security practices in some areas but has significant gaps that require immediate attention, particularly in authentication, input validation, and configuration security.
### Risk Classification
- **🔴 Critical (17)**: Immediate action required - vulnerabilities that could lead to system compromise
- **🟠 High (12)**: High priority - significant security risks
- **🟡 Medium (8)**: Medium priority - security improvements needed
- **🟢 Low (15)**: Best practice improvements
---
## Critical Security Vulnerabilities (🔴)
### C-01: Authentication Bypass via Header Manipulation
**File**: `internal/handlers/auth.go:46`, `internal/middleware/middleware.go`
**Severity**: Critical
**CVSS Score**: 9.8
The system relies solely on the `X-User-Email` header for user identification without verification.
```go
userID := c.GetHeader("X-User-Email")
if userID == "" {
h.logger.Warn("User email not found in headers")
// ... returns 401
}
```
**Impact**: Attackers can bypass authentication by setting arbitrary `X-User-Email` headers.
**Recommendation**: Implement proper JWT/OAuth2 token validation or header signature verification.
### C-02: Hardcoded Production Secrets
**File**: `internal/config/config.go:110,117,163`
**Severity**: Critical
Default production secrets are hardcoded:
- JWT Secret: `"bootstrap-jwt-secret-change-in-production"`
- HMAC Key: `"bootstrap-hmac-key-change-in-production"`
**Impact**: Production deployments using default secrets are completely compromised.
**Recommendation**: Force secret generation on startup if defaults are detected.
### C-03: SQL Injection Vulnerability in Dynamic Queries
**File**: `internal/repository/postgres/application_repository.go:281-285`
**Severity**: Critical
Dynamic query construction without proper parameterization:
```go
query := fmt.Sprintf(`
UPDATE applications
SET %s
WHERE app_id = $%d
`, strings.Join(setParts, ", "), argIndex)
```
**Impact**: Potential SQL injection through crafted update requests.
**Recommendation**: Use proper parameterized queries for all dynamic SQL.
### C-04: JWT Token Stored in URL Parameters
**File**: `internal/handlers/auth.go:72-73`
**Severity**: Critical
JWT tokens are passed via URL query parameters:
```go
response := domain.LoginResponse{
RedirectURL: req.RedirectURI + "?token=" + token,
}
```
**Impact**: Tokens exposed in server logs, browser history, and referrer headers.
**Recommendation**: Use POST requests or secure cookie-based token delivery.
### C-05: Weak Token Generation Fallback
**File**: `internal/auth/jwt.go:275-277`
**Severity**: Critical
Fallback to predictable timestamp-based token IDs:
```go
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("jti_%d", time.Now().UnixNano())
}
```
**Impact**: Predictable token IDs enable token enumeration attacks.
**Recommendation**: Fail securely - abort token generation if crypto/rand fails.
### C-06: Information Disclosure in Error Messages
**File**: Multiple files including `internal/handlers/*.go`
**Severity**: Critical
Database and system errors are exposed to clients:
```go
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid request body: " + err.Error(),
})
```
**Impact**: Internal system information leaked to attackers.
**Recommendation**: Log detailed errors internally, return generic messages to clients.
### C-07: No Rate Limiting on Authentication Endpoints
**File**: Authentication handlers lack rate limiting
**Severity**: Critical
Authentication endpoints are not protected by rate limiting, enabling brute force attacks.
**Impact**: Unlimited authentication attempts possible.
**Recommendation**: Implement strict rate limiting on `/api/login` and `/api/verify`.
### C-08: Missing CSRF Protection
**File**: All API handlers
**Severity**: Critical
No CSRF token validation on state-changing operations.
**Impact**: Cross-site request forgery attacks possible.
**Recommendation**: Implement CSRF protection for all POST/PUT/DELETE requests.
### C-09: Insecure Password Storage (bcrypt with default cost)
**File**: `internal/crypto/token.go:73`
**Severity**: Critical
Using bcrypt with default cost (10) which is insufficient for 2025:
```go
hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost)
```
**Impact**: Tokens vulnerable to offline brute force attacks.
**Recommendation**: Use minimum cost of 14 for bcrypt in 2025.
### C-10: Lack of Input Validation on Critical Fields
**File**: `internal/handlers/application.go`, `internal/handlers/token.go`
**Severity**: Critical
No validation on critical fields like app_id, permissions, callback URLs.
**Impact**: Injection attacks, malicious redirects, privilege escalation.
**Recommendation**: Implement comprehensive input validation.
### C-11: Missing Authorization Checks
**File**: `internal/handlers/application.go:164` (delete)
**Severity**: Critical
No verification that users can only access/modify their own resources.
**Impact**: Users can modify/delete other users' applications and tokens.
**Recommendation**: Implement resource ownership validation.
### C-12: Timing Attack Vulnerability
**File**: `internal/crypto/token.go:82-84`
**Severity**: Critical
Non-constant time token comparison:
```go
func (tg *TokenGenerator) VerifyToken(token, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(token))
return err == nil
}
```
**Impact**: Token hashes vulnerable to timing attacks.
**Recommendation**: bcrypt.CompareHashAndPassword is already constant-time, but error handling should be consistent.
### C-13: Database Connection String in Logs
**File**: `internal/config/config.go:271`
**Severity**: Critical
Database passwords logged in connection strings:
```go
func (c *Config) GetDatabaseDSN() string {
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
// ... includes password
)
}
```
**Impact**: Database credentials exposed in logs.
**Recommendation**: Mask passwords in connection strings used for logging.
### C-14: Insufficient Session Security
**File**: `internal/domain/session.go`
**Severity**: Critical
Sessions lack proper security attributes:
- No secure cookie flags
- No SameSite protection
- No proper session invalidation
**Impact**: Session hijacking via XSS or network interception.
**Recommendation**: Implement secure session management.
### C-15: JWT Secret Exposed in Token Info
**File**: `internal/auth/jwt.go:282-300`
**Severity**: Critical
Token debugging function may expose sensitive information:
```go
func (j *JWTManager) GetTokenInfo(tokenString string) map[string]interface{} {
// Returns all claims without filtering
}
```
**Impact**: Internal system information disclosure.
**Recommendation**: Filter sensitive fields in debugging output.
### C-16: Cache Poisoning Vulnerability
**File**: `internal/cache/cache.go`
**Severity**: Critical
No validation of cached data integrity.
**Impact**: Attackers could poison cache with malicious data.
**Recommendation**: Implement cache entry validation and integrity checks.
### C-17: Unrestricted File Upload (Docker Volumes)
**File**: `docker-compose.yml:74`
**Severity**: Critical
Docker volumes mounted without proper restrictions:
```yaml
volumes:
- ./migrations:/app/migrations:ro,Z
```
**Impact**: Potential container escape if migrations directory is writable.
**Recommendation**: Ensure proper file permissions and use read-only mounts.
---
## High Risk Issues (🟠)
### H-01: Weak HMAC Signature Validation
**File**: `internal/middleware/security.go:404,468-495`
Missing timestamp validation and replay attack prevention in HMAC signature validation.
### H-02: Insufficient HTTPS Enforcement
**File**: `internal/middleware/security.go:139-141`
HSTS headers only set when TLS is detected, not enforced.
### H-03: Permissive CORS Configuration
**File**: Missing CORS configuration
No explicit CORS policy defined, potentially allowing any origin.
### H-04: Inadequate Logging of Security Events
**File**: Multiple security middleware files
Security failures not properly logged for monitoring.
### H-05: Missing Security Headers
**File**: `internal/middleware/security.go:128-145`
Several important security headers missing:
- `Permissions-Policy`
- `Cross-Origin-Embedder-Policy`
- `Cross-Origin-Resource-Policy`
### H-06: Insecure Random Number Generation Fallback
**File**: `internal/crypto/token.go:44-46`
No validation that crypto/rand is working properly.
### H-07: Database Connection Pool Vulnerabilities
**File**: `internal/database/postgres.go`
No proper connection validation or pool exhaustion protection.
### H-08: Metrics Endpoint Security
**File**: Docker expose port 9090
Metrics endpoint exposed without authentication.
### H-09: Debug Information Leakage
**File**: Multiple files with debug logging
Extensive debug logging may expose sensitive information in production.
### H-10: Insufficient Audit Logging
**File**: `internal/audit/audit.go`
Audit logs missing critical security events like failed authentications.
### H-11: Frontend XSS Vulnerabilities
**File**: `kms-frontend/src/services/apiService.ts:114-116`
User input parsed and used without proper sanitization:
```typescript
const userData = JSON.parse(user);
config.headers['X-User-Email'] = userData.email;
```
### H-12: Environment Variable Injection
**File**: `kms-frontend/src/services/apiService.ts:98`
Base URL from environment variables without validation.
---
## Medium Risk Issues (🟡)
### M-01: Incomplete Error Handling
**File**: Multiple files
Inconsistent error handling patterns across the codebase.
### M-02: Missing API Versioning
**File**: API endpoints
No proper API versioning strategy implemented.
### M-03: Insufficient Input Length Limits
**File**: Database schema and API handlers
No explicit length limits on user inputs.
### M-04: Weak Password Complexity Requirements
**File**: No password policy enforcement
No password complexity requirements defined.
### M-05: Missing Request ID Tracing
**File**: Logging infrastructure
No request ID correlation for security incident investigation.
### M-06: Inadequate Database Index Security
**File**: `migrations/001_initial_schema.up.sql`
Some indexes might leak information through timing attacks.
### M-07: Cache TTL Security
**File**: `internal/cache/cache.go`
No validation of cache TTL values, potentially allowing cache flooding.
### M-08: File Permission Issues
**File**: Docker configuration
Potential file permission issues in Docker deployment.
---
## Incomplete/Dead Code Analysis
### TODO Items Found
- `internal/auth/permissions.go:296`: "TODO: In a real implementation, this would:"
- Incomplete permission evaluation system
- Hardcoded test permissions instead of database-driven
### Debug Code in Production
Multiple debug endpoints and verbose logging enabled in production configuration.
### Unused Security Features
- SAML authentication implemented but not properly configured
- OAuth2 providers implemented but not fully integrated
- Redis caching available but disabled by default
### Missing Components
- Rate limiting implementation incomplete
- Token revocation not fully implemented
- Session management incomplete
- Audit trail system basic
---
## Security Architecture Gaps
### 1. Authentication Architecture
- Relies on unverified HTTP headers
- No multi-factor authentication
- No account lockout mechanisms
- No session timeout enforcement
### 2. Authorization Model
- Insufficient granular permissions
- No resource-level access control
- Missing role-based access control implementation
- Hierarchical permissions not enforced
### 3. Data Protection
- No data encryption at rest
- No field-level encryption for sensitive data
- Missing data anonymization capabilities
- No secure data deletion
### 4. Network Security
- Missing network segmentation
- No SSL/TLS termination configuration
- Inadequate firewall rules
- Missing VPC/network isolation
### 5. Monitoring & Alerting
- No security event monitoring
- Missing intrusion detection
- No automated security alerts
- Insufficient audit trail
---
## Recommended Immediate Actions
### Priority 1 (Fix Immediately)
1. Replace all hardcoded secrets with secure generation
2. Implement proper authentication validation
3. Add input validation on all endpoints
4. Fix SQL injection vulnerability
5. Remove token from URL parameters
6. Add authorization checks to all resources
### Priority 2 (Fix Within 1 Week)
1. Implement proper rate limiting
2. Add CSRF protection
3. Secure session management
4. Add comprehensive security headers
5. Fix error message information disclosure
6. Implement proper HTTPS enforcement
### Priority 3 (Fix Within 1 Month)
1. Complete audit logging system
2. Implement proper CORS policy
3. Add API versioning
4. Enhance monitoring and alerting
5. Complete security testing
6. Document security procedures
---
## Security Testing Recommendations
### Automated Security Testing
1. **SAST (Static Application Security Testing)**
- Run gosec for Go code analysis
- Use ESLint security rules for React frontend
- Implement pre-commit security hooks
2. **DAST (Dynamic Application Security Testing)**
- OWASP ZAP scanning
- SQL injection testing
- Authentication bypass testing
- Rate limiting validation
3. **Container Security**
- Scan Docker images for vulnerabilities
- Validate container configurations
- Check for privilege escalation
### Manual Security Testing
1. **Penetration Testing**
- Authentication mechanisms
- Authorization bypass attempts
- Input validation testing
- Session management testing
2. **Code Review**
- Security-focused code reviews
- Architecture security assessment
- Threat modeling exercises
---
## Compliance Considerations
### Standards Alignment
- **OWASP Top 10 2021**: Multiple vulnerabilities identified
- **NIST Cybersecurity Framework**: Gaps in Identify, Protect, and Detect functions
- **ISO 27001**: Missing security controls and procedures
### Regulatory Compliance
- **GDPR**: Missing data protection controls
- **SOC 2**: Insufficient security controls for Trust Services Criteria
- **PCI DSS**: Not applicable but good security practices missing
---
## Conclusion
The KMS system shows architectural understanding but has critical security vulnerabilities that must be addressed immediately. The authentication system is particularly vulnerable and requires complete redesign. Input validation, error handling, and secure configuration management need significant improvement.
**Overall Security Rating: HIGH RISK** ⚠️
Immediate action is required to address the critical vulnerabilities before any production deployment. A comprehensive security remediation plan should be implemented with regular security assessments.
---
**Report Generation Date**: 2025-08-23
**Next Review Recommended**: After critical issues are resolved
**Contact**: This is an automated security analysis report

View File

@ -0,0 +1,879 @@
# KMS System Implementation Guide
This document provides detailed implementation guidance for the KMS system, covering areas not extensively documented in other files. It serves as a comprehensive reference for developers working on system components.
## Table of Contents
1. [Documentation Consistency Analysis](#documentation-consistency-analysis)
2. [Audit System Implementation](#audit-system-implementation)
3. [Multi-Tenancy Support](#multi-tenancy-support)
4. [Cache Implementation Details](#cache-implementation-details)
5. [Error Handling Framework](#error-handling-framework)
6. [Validation System](#validation-system)
7. [Metrics and Monitoring](#metrics-and-monitoring)
8. [Database Migration System](#database-migration-system)
9. [Frontend Architecture](#frontend-architecture)
10. [Configuration Management](#configuration-management)
---
## Documentation Consistency Analysis
### Current State Assessment
The existing documentation is comprehensive but has some minor inconsistencies with the actual codebase:
#### ✅ Accurate Documentation Areas:
- **API endpoints** match the implementation in handlers
- **Database schema** aligns with migrations (especially the new audit_events table)
- **Authentication flows** are correctly documented
- **Docker compose setup** matches actual configuration
- **Security architecture** accurately reflects implementation
- **Permission system** documentation is consistent with code
#### ⚠️ Minor Inconsistencies Found:
1. **Port references**: Some docs mention port 80 but actual nginx runs on 8081
2. **Container names**: Documentation uses generic names, actual compose uses specific names like `kms-postgres`
3. **Rate limiting values**: Docs show different values than actual middleware implementation
4. **Frontend build process**: React version mentioned as 18, but package.json shows 19+
#### ✨ Recently Added Features (Not in Original Docs):
- **Audit system** with comprehensive event logging
- **Multi-tenancy support** in database schema
- **Advanced caching layer** with Redis integration
- **SAML authentication** implementation
- **Advanced security middleware** with brute force protection
---
## Audit System Implementation
### Overview
The KMS implements a comprehensive audit logging system that tracks all system events, user actions, and security-related activities.
### Core Components
#### Audit Event Structure
```go
// File: internal/audit/audit.go
type AuditEvent struct {
ID uuid.UUID `json:"id"`
Type EventType `json:"type"`
Severity Severity `json:"severity"`
Status Status `json:"status"`
Timestamp time.Time `json:"timestamp"`
// Actor information
ActorID string `json:"actor_id"`
ActorType ActorType `json:"actor_type"`
ActorIP string `json:"actor_ip"`
UserAgent string `json:"user_agent"`
// Multi-tenancy support
TenantID *uuid.UUID `json:"tenant_id,omitempty"`
// Resource information
ResourceID string `json:"resource_id"`
ResourceType string `json:"resource_type"`
// Event details
Action string `json:"action"`
Description string `json:"description"`
Details map[string]interface{} `json:"details"`
// Request context
RequestID string `json:"request_id"`
SessionID string `json:"session_id"`
// Metadata
Tags []string `json:"tags"`
Metadata map[string]interface{} `json:"metadata"`
}
```
#### Event Types Taxonomy
```
auth.* - Authentication events
├── auth.login - Successful user login
├── auth.login_failed - Failed login attempt
├── auth.logout - User logout
├── auth.token_created - Token generation
├── auth.token_revoked - Token revocation
└── auth.token_validated - Token validation
session.* - Session management
├── session.created - New session created
├── session.revoked - Session terminated
└── session.expired - Session timeout
app.* - Application management
├── app.created - Application created
├── app.updated - Application modified
└── app.deleted - Application removed
permission.* - Permission operations
├── permission.granted - Permission assigned
├── permission.revoked - Permission removed
└── permission.denied - Access denied
tenant.* - Multi-tenant operations
├── tenant.created - New tenant
├── tenant.updated - Tenant modified
├── tenant.suspended - Tenant suspended
└── tenant.activated - Tenant reactivated
```
#### Database Schema
```sql
-- File: migrations/004_add_audit_events.up.sql
CREATE TABLE audit_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('info', 'warning', 'error', 'critical')),
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failure', 'pending')),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Actor information
actor_id VARCHAR(255),
actor_type VARCHAR(50) CHECK (actor_type IN ('user', 'system', 'service')),
actor_ip INET,
user_agent TEXT,
-- Multi-tenancy
tenant_id UUID,
-- Resource tracking
resource_id VARCHAR(255),
resource_type VARCHAR(100),
action VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
details JSONB DEFAULT '{}',
-- Request context
request_id VARCHAR(100),
session_id VARCHAR(255),
-- Metadata
tags TEXT[],
metadata JSONB DEFAULT '{}'
);
```
#### Frontend Integration
```typescript
// File: kms-frontend/src/components/Audit.tsx
interface AuditEvent {
id: string;
type: string;
severity: 'info' | 'warning' | 'error' | 'critical';
status: 'success' | 'failure' | 'pending';
timestamp: string;
actor_id: string;
actor_type: string;
resource_type: string;
action: string;
description: string;
}
const Audit: React.FC = () => {
// Real-time audit log viewing with filtering
// Timeline view for event sequences
// Statistics dashboard for audit metrics
};
```
### Implementation Guidelines
#### Logging Best Practices
1. **Log all security-relevant events**
2. **Include sufficient context** for forensic analysis
3. **Use structured logging** with consistent fields
4. **Implement log retention policies**
5. **Ensure tamper-evident logging**
#### Performance Considerations
1. **Asynchronous logging** to avoid blocking operations
2. **Batch inserts** for high-volume events
3. **Proper indexing** on commonly queried fields
4. **Archival strategy** for historical data
---
## Multi-Tenancy Support
### Architecture
The KMS implements a multi-tenant architecture where each tenant has isolated data and permissions while sharing the same application instance.
### Database Design
#### Tenant Model
```go
// File: internal/domain/tenant.go
type Tenant struct {
ID uuid.UUID `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Slug string `json:"slug" db:"slug"`
Status TenantStatus `json:"status" db:"status"`
Settings TenantSettings `json:"settings" db:"settings"`
Metadata map[string]interface{} `json:"metadata" db:"metadata"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type TenantStatus string
const (
TenantStatusActive TenantStatus = "active"
TenantStatusSuspended TenantStatus = "suspended"
TenantStatusPending TenantStatus = "pending"
)
```
#### Data Isolation Strategy
```sql
-- All tenant-specific tables include tenant_id
ALTER TABLE applications ADD COLUMN tenant_id UUID REFERENCES tenants(id);
ALTER TABLE static_tokens ADD COLUMN tenant_id UUID REFERENCES tenants(id);
ALTER TABLE user_sessions ADD COLUMN tenant_id UUID REFERENCES tenants(id);
ALTER TABLE audit_events ADD COLUMN tenant_id UUID;
-- Row-level security policies
ALTER TABLE applications ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON applications
FOR ALL TO kms_user
USING (tenant_id = current_setting('app.current_tenant')::UUID);
```
### Implementation Pattern
#### Tenant Context Middleware
```go
// File: internal/middleware/tenant.go
func TenantMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := extractTenantID(c)
if tenantID == "" {
c.AbortWithStatusJSON(400, gin.H{"error": "tenant_required"})
return
}
// Set tenant context
c.Set("tenant_id", tenantID)
// Set database session variable
db := c.MustGet("db").(*sql.DB)
_, err := db.Exec("SELECT set_config('app.current_tenant', $1, true)", tenantID)
if err != nil {
c.AbortWithStatusJSON(500, gin.H{"error": "tenant_setup_failed"})
return
}
c.Next()
}
}
```
### Usage Guidelines
1. **Always include tenant_id** in database queries
2. **Validate tenant access** in middleware
3. **Implement tenant-aware caching**
4. **Audit cross-tenant operations**
5. **Test tenant isolation thoroughly**
---
## Cache Implementation Details
### Architecture
The KMS implements a layered caching system with multiple providers and configurable TTL policies.
### Cache Interface
```go
// File: internal/cache/cache.go
type CacheManager interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
GetJSON(ctx context.Context, key string, dest interface{}) error
SetJSON(ctx context.Context, key string, value interface{}, ttl time.Duration) error
Delete(ctx context.Context, key string) error
Clear(ctx context.Context) error
Exists(ctx context.Context, key string) (bool, error)
}
```
### Redis Implementation
```go
// File: internal/cache/redis.go
type RedisCacheManager struct {
client redis.Client
keyPrefix string
serializer JSONSerializer
logger *zap.Logger
}
func (r *RedisCacheManager) GetJSON(ctx context.Context, key string, dest interface{}) error {
prefixedKey := r.keyPrefix + key
data, err := r.client.Get(ctx, prefixedKey).Bytes()
if err != nil {
if err == redis.Nil {
return ErrCacheMiss
}
return fmt.Errorf("failed to get key %s: %w", prefixedKey, err)
}
return r.serializer.Deserialize(data, dest)
}
```
### Cache Key Management
```go
type CacheKey string
const (
KeyPrefixAuth = "auth:"
KeyPrefixToken = "token:"
KeyPrefixPermission = "perm:"
KeyPrefixSession = "sess:"
KeyPrefixApp = "app:"
)
func CacheKey(prefix, suffix string) string {
return fmt.Sprintf("%s%s", prefix, suffix)
}
```
### Usage Patterns
#### Authentication Caching
```go
// Cache authentication results for 5 minutes
cacheKey := cache.CacheKey(cache.KeyPrefixAuth, fmt.Sprintf("%s:%s", userID, appID))
err := cacheManager.SetJSON(ctx, cacheKey, authResult, 5*time.Minute)
```
#### Token Revocation List
```go
// Cache revoked tokens until their expiration
revokedKey := cache.CacheKey(cache.KeyPrefixToken, "revoked:"+tokenID)
err := cacheManager.Set(ctx, revokedKey, []byte("1"), tokenExpiry.Sub(time.Now()))
```
### Configuration
```bash
# Cache configuration
CACHE_ENABLED=true
CACHE_PROVIDER=redis # or memory
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
CACHE_DEFAULT_TTL=5m
```
---
## Error Handling Framework
### Error Type Hierarchy
```go
// File: internal/errors/errors.go
type ErrorCode string
const (
ErrorCodeValidation ErrorCode = "validation_error"
ErrorCodeAuthentication ErrorCode = "authentication_error"
ErrorCodeAuthorization ErrorCode = "authorization_error"
ErrorCodeNotFound ErrorCode = "not_found"
ErrorCodeConflict ErrorCode = "conflict"
ErrorCodeInternal ErrorCode = "internal_error"
ErrorCodeRateLimit ErrorCode = "rate_limit_exceeded"
ErrorCodeBadRequest ErrorCode = "bad_request"
)
type APIError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details interface{} `json:"details,omitempty"`
HTTPStatus int `json:"-"`
Cause error `json:"-"`
}
```
### Error Factory Functions
```go
func NewValidationError(message string, details interface{}) *APIError {
return &APIError{
Code: ErrorCodeValidation,
Message: message,
Details: details,
HTTPStatus: http.StatusBadRequest,
}
}
func NewAuthenticationError(message string) *APIError {
return &APIError{
Code: ErrorCodeAuthentication,
Message: message,
HTTPStatus: http.StatusUnauthorized,
}
}
```
### Error Handler Middleware
```go
// File: internal/errors/secure_responses.go
func (e *ErrorHandler) HandleError(c *gin.Context, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
// Log error with context
e.logger.Error("API error",
zap.String("error_code", string(apiErr.Code)),
zap.String("message", apiErr.Message),
zap.Int("http_status", apiErr.HTTPStatus),
zap.Error(apiErr.Cause))
c.JSON(apiErr.HTTPStatus, gin.H{
"error": apiErr.Code,
"message": apiErr.Message,
"details": apiErr.Details,
})
return
}
// Handle unexpected errors
e.logger.Error("Unexpected error", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": ErrorCodeInternal,
"message": "An internal error occurred",
})
}
```
---
## Validation System
### Validator Implementation
```go
// File: internal/validation/validator.go
type Validator struct {
validator *validator.Validate
logger *zap.Logger
}
func NewValidator(logger *zap.Logger) *Validator {
v := validator.New()
// Register custom validators
v.RegisterValidation("app_id", validateAppID)
v.RegisterValidation("token_type", validateTokenType)
v.RegisterValidation("permission_scope", validatePermissionScope)
return &Validator{
validator: v,
logger: logger,
}
}
```
### Custom Validation Rules
```go
func validateAppID(fl validator.FieldLevel) bool {
appID := fl.Field().String()
// App ID format: domain.app (e.g., com.example.app)
pattern := `^[a-z0-9]+(\.[a-z0-9]+)*\.[a-z0-9]+$`
match, _ := regexp.MatchString(pattern, appID)
return match && len(appID) >= 3 && len(appID) <= 100
}
func validatePermissionScope(fl validator.FieldLevel) bool {
scope := fl.Field().String()
// Permission format: domain.action (e.g., app.read)
pattern := `^[a-z_]+(\.[a-z_]+)*$`
match, _ := regexp.MatchString(pattern, scope)
return match && len(scope) >= 1 && len(scope) <= 50
}
```
### Middleware Integration
```go
// File: internal/middleware/validation.go
func (v *ValidationMiddleware) ValidateJSON(schema interface{}) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
if err := c.ShouldBindJSON(schema); err != nil {
var validationErrors []ValidationError
if errs, ok := err.(validator.ValidationErrors); ok {
for _, e := range errs {
validationErrors = append(validationErrors, ValidationError{
Field: e.Field(),
Message: e.Tag(),
Value: e.Value(),
})
}
}
apiErr := errors.NewValidationError("Request validation failed", validationErrors)
v.errorHandler.HandleError(c, apiErr)
return
}
c.Next()
})
}
```
---
## Metrics and Monitoring
### Prometheus Integration
```go
// File: internal/metrics/metrics.go
type Metrics struct {
// HTTP metrics
httpRequestsTotal *prometheus.CounterVec
httpRequestDuration *prometheus.HistogramVec
httpRequestsInFlight prometheus.Gauge
// Auth metrics
authAttemptsTotal *prometheus.CounterVec
authSuccessTotal *prometheus.CounterVec
authFailuresTotal *prometheus.CounterVec
// Token metrics
tokensIssuedTotal *prometheus.CounterVec
tokenValidationsTotal *prometheus.CounterVec
// Business metrics
applicationsTotal prometheus.Gauge
activeSessionsTotal prometheus.Gauge
}
```
### Metrics Collection
```go
func (m *Metrics) RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) {
m.httpRequestsTotal.WithLabelValues(method, path, strconv.Itoa(statusCode)).Inc()
m.httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
}
func (m *Metrics) RecordAuthAttempt(provider, result string) {
m.authAttemptsTotal.WithLabelValues(provider, result).Inc()
if result == "success" {
m.authSuccessTotal.WithLabelValues(provider).Inc()
} else {
m.authFailuresTotal.WithLabelValues(provider).Inc()
}
}
```
### Dashboard Configuration
```yaml
# Grafana dashboard config
panels:
- title: "Request Rate"
type: "graph"
targets:
- expr: "rate(http_requests_total[5m])"
legendFormat: "{{method}} {{path}}"
- title: "Authentication Success Rate"
type: "stat"
targets:
- expr: "rate(auth_success_total[5m]) / rate(auth_attempts_total[5m]) * 100"
legendFormat: "Success Rate %"
- title: "Active Applications"
type: "stat"
targets:
- expr: "applications_total"
legendFormat: "Applications"
```
---
## Database Migration System
### Migration Structure
```
migrations/
├── 001_initial_schema.up.sql
├── 001_initial_schema.down.sql
├── 002_user_sessions.up.sql
├── 002_user_sessions.down.sql
├── 003_add_token_prefix.up.sql
├── 003_add_token_prefix.down.sql
├── 004_add_audit_events.up.sql
└── 004_add_audit_events.down.sql
```
### Migration Runner
```go
// File: internal/database/postgres.go
func RunMigrations(db *sql.DB, migrationPath string) error {
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("failed to create migration driver: %w", err)
}
m, err := migrate.NewWithDatabaseInstance(
fmt.Sprintf("file://%s", migrationPath),
"postgres", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %w", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}
```
### Migration Best Practices
1. **Always create both up and down migrations**
2. **Test migrations on copy of production data**
3. **Make migrations idempotent**
4. **Add proper indexes for performance**
5. **Include rollback procedures**
### Example Migration
```sql
-- 005_add_oauth_providers.up.sql
CREATE TABLE IF NOT EXISTS oauth_providers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
client_secret_encrypted TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
user_info_url TEXT NOT NULL,
scopes TEXT[] DEFAULT ARRAY['openid', 'profile', 'email'],
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_oauth_providers_name ON oauth_providers(name);
CREATE INDEX idx_oauth_providers_enabled ON oauth_providers(enabled) WHERE enabled = true;
```
---
## Frontend Architecture
### Component Structure
```
src/
├── components/
│ ├── Applications.tsx # Application management
│ ├── Tokens.tsx # Token operations
│ ├── Users.tsx # User management
│ ├── Audit.tsx # Audit log viewer
│ ├── Dashboard.tsx # Main dashboard
│ ├── Login.tsx # Authentication
│ ├── TokenTester.tsx # Token testing utility
│ └── TokenTesterCallback.tsx
├── contexts/
│ └── AuthContext.tsx # Authentication state
├── services/
│ └── apiService.ts # API client
├── App.tsx # Main application
└── index.tsx # Entry point
```
### API Service Implementation
```typescript
// File: kms-frontend/src/services/apiService.ts
class APIService {
private baseURL: string;
private token: string | null = null;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
'X-User-Email': this.getUserEmail(),
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new APIError(error.message || 'Request failed', response.status);
}
return response.json();
}
// Application management
async getApplications(): Promise<Application[]> {
return this.request<Application[]>('/api/applications');
}
// Audit log access
async getAuditEvents(params: AuditQueryParams): Promise<AuditEvent[]> {
const queryString = new URLSearchParams(params).toString();
return this.request<AuditEvent[]>(`/api/audit/events?${queryString}`);
}
}
```
### Authentication Context
```typescript
// File: kms-frontend/src/contexts/AuthContext.tsx
interface AuthContextType {
user: User | null;
login: (email: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
isLoading: boolean;
}
export const AuthContext = React.createContext<AuthContextType | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const login = async (email: string) => {
try {
setIsLoading(true);
const response = await apiService.login(email);
setUser({ email, token: response.token });
localStorage.setItem('kms_user', JSON.stringify({ email }));
} catch (error) {
throw error;
} finally {
setIsLoading(false);
}
};
// ... rest of implementation
};
```
---
## Configuration Management
### Configuration Interface
```go
// File: internal/config/config.go
type ConfigProvider interface {
GetString(key string) string
GetInt(key string) int
GetBool(key string) bool
GetDuration(key string) time.Duration
GetStringSlice(key string) []string
IsSet(key string) bool
Validate() error
GetDatabaseDSN() string
GetServerAddress() string
IsDevelopment() bool
IsProduction() bool
}
```
### Configuration Validation
```go
func (c *Config) Validate() error {
var errors []string
// Required configuration
required := []string{
"INTERNAL_HMAC_KEY",
"JWT_SECRET",
"AUTH_SIGNING_KEY",
"DB_HOST",
"DB_NAME",
}
for _, key := range required {
if !c.IsSet(key) {
errors = append(errors, fmt.Sprintf("required configuration %s is not set", key))
}
}
// Validate key lengths
if len(c.GetString("INTERNAL_HMAC_KEY")) < 32 {
errors = append(errors, "INTERNAL_HMAC_KEY must be at least 32 characters")
}
if len(errors) > 0 {
return fmt.Errorf("configuration validation failed: %s", strings.Join(errors, ", "))
}
return nil
}
```
### Environment Configuration
```bash
# Security Configuration
INTERNAL_HMAC_KEY=3924f352b7ea63b27db02bf4b0014f2961a5d2f7c27643853a4581bb3a5457cb
JWT_SECRET=7f5e11d55e957988b00ce002418680af384219ef98c50d08cbbbdd541978450c
AUTH_SIGNING_KEY=484f921b39c383e6b3e0cc5a7cef3c2cec3d7c8d474ab5102891dc4c2bf63a68
# Database Configuration
DB_HOST=postgres
DB_PORT=5432
DB_NAME=kms
DB_USER=postgres
DB_PASSWORD=postgres
# Feature Flags
RATE_LIMIT_ENABLED=true
CACHE_ENABLED=false
METRICS_ENABLED=true
SAML_ENABLED=false
```
---
## Implementation Best Practices
### Code Organization
1. **Follow clean architecture principles**
2. **Use dependency injection throughout**
3. **Implement comprehensive error handling**
4. **Add structured logging to all components**
5. **Write unit tests for business logic**
### Security Guidelines
1. **Always validate input at API boundaries**
2. **Use parameterized database queries**
3. **Implement proper authentication and authorization**
4. **Log all security-relevant events**
5. **Follow principle of least privilege**
### Performance Considerations
1. **Implement caching for frequently accessed data**
2. **Use database indexes appropriately**
3. **Monitor and optimize slow queries**
4. **Implement proper connection pooling**
5. **Use asynchronous operations where beneficial**
### Testing Strategy
1. **Unit tests for business logic**
2. **Integration tests for API endpoints**
3. **End-to-end tests for critical workflows**
4. **Load testing for performance validation**
5. **Security testing for vulnerability assessment**
---
*This document serves as a comprehensive implementation guide for the KMS system. It should be updated as the system evolves and new features are added.*

View File

@ -136,6 +136,9 @@ type AuditLogger interface {
// QueryEvents queries audit events with filters
QueryEvents(ctx context.Context, filter *AuditFilter) ([]*AuditEvent, error)
// GetEventByID retrieves a specific audit event by ID
GetEventByID(ctx context.Context, eventID uuid.UUID) (*AuditEvent, error)
// GetEventStats returns audit event statistics
GetEventStats(ctx context.Context, filter *AuditStatsFilter) (*AuditStats, error)
}
@ -188,6 +191,7 @@ type auditLogger struct {
type AuditRepository interface {
Create(ctx context.Context, event *AuditEvent) error
Query(ctx context.Context, filter *AuditFilter) ([]*AuditEvent, error)
GetByID(ctx context.Context, eventID uuid.UUID) (*AuditEvent, error)
GetStats(ctx context.Context, filter *AuditStatsFilter) (*AuditStats, error)
DeleteOldEvents(ctx context.Context, olderThan time.Time) (int, error)
}
@ -353,6 +357,11 @@ func (a *auditLogger) QueryEvents(ctx context.Context, filter *AuditFilter) ([]*
return a.repository.Query(ctx, filter)
}
// GetEventByID retrieves a specific audit event by ID
func (a *auditLogger) GetEventByID(ctx context.Context, eventID uuid.UUID) (*AuditEvent, error) {
return a.repository.GetByID(ctx, eventID)
}
// GetEventStats returns audit event statistics
func (a *auditLogger) GetEventStats(ctx context.Context, filter *AuditStatsFilter) (*AuditStats, error) {
return a.repository.GetStats(ctx, filter)

View File

@ -282,42 +282,38 @@ func (pm *PermissionManager) evaluatePermission(ctx context.Context, userID, app
Metadata: make(map[string]string),
}
// TODO: In a real implementation, this would:
// 1. Fetch user roles from database
// 2. Resolve role permissions
// 3. Check hierarchical permissions
// 4. Apply context-specific rules
// For now, implement basic logic
// 1. Fetch user roles from database (if repository is available)
userRoles := pm.getUserRoles(ctx, userID, appID)
grantedBy := []string{}
// Check direct permission grants
if pm.hasDirectPermission(userID, appID, permission) {
// 2. Check direct permission grants via repository
if pm.hasDirectPermissionFromRepo(ctx, userID, appID, permission) {
grantedBy = append(grantedBy, "direct")
}
// Check role-based permissions
// 3. Check role-based permissions
for _, role := range userRoles {
if pm.roleHasPermission(role, permission) {
grantedBy = append(grantedBy, fmt.Sprintf("role:%s", role))
}
}
// Check hierarchical permissions
// 4. Check hierarchical permissions (parent permissions grant child permissions)
if len(grantedBy) == 0 {
if inheritedPermissions := pm.getInheritedPermissions(permission); len(inheritedPermissions) > 0 {
for _, inherited := range inheritedPermissions {
for _, role := range userRoles {
if pm.roleHasPermission(role, inherited) {
grantedBy = append(grantedBy, fmt.Sprintf("inherited:%s", inherited))
break
}
}
if parentPermission := pm.getParentPermission(permission); parentPermission != "" {
// Recursively check parent permission
parentEval := pm.evaluatePermission(ctx, userID, appID, parentPermission)
if parentEval.Granted {
grantedBy = append(grantedBy, fmt.Sprintf("inherited:%s", parentPermission))
}
}
}
// 5. Apply context-specific rules
if len(grantedBy) == 0 && pm.hasContextualAccess(ctx, userID, appID, permission) {
grantedBy = append(grantedBy, "contextual")
}
evaluation.Granted = len(grantedBy) > 0
evaluation.GrantedBy = grantedBy
@ -328,7 +324,7 @@ func (pm *PermissionManager) evaluatePermission(ctx context.Context, userID, app
// Add metadata
evaluation.Metadata["user_roles"] = strings.Join(userRoles, ",")
evaluation.Metadata["app_id"] = appID
evaluation.Metadata["evaluation_method"] = "hierarchical"
evaluation.Metadata["evaluation_method"] = "hierarchical_with_repository"
return evaluation
}
@ -686,3 +682,68 @@ func (h *PermissionHierarchy) ListRoles() []*Role {
return roles
}
// hasDirectPermissionFromRepo checks if user has direct permission via repository lookup
func (pm *PermissionManager) hasDirectPermissionFromRepo(ctx context.Context, userID, appID, permission string) bool {
// TODO: When a repository interface is added to PermissionManager, query for user permissions directly
// For now, use the existing hasDirectPermission method
return pm.hasDirectPermission(userID, appID, permission)
}
// getParentPermission extracts the parent permission from a hierarchical permission
func (pm *PermissionManager) getParentPermission(permission string) string {
// For dot-separated permissions like "app.create", parent is "app"
if lastDot := strings.LastIndex(permission, "."); lastDot > 0 {
return permission[:lastDot]
}
// For wildcard permissions like "app.*", parent is "app"
if strings.HasSuffix(permission, ".*") {
return strings.TrimSuffix(permission, ".*")
}
return ""
}
// hasContextualAccess applies context-specific permission rules
func (pm *PermissionManager) hasContextualAccess(ctx context.Context, userID, appID, permission string) bool {
// Context-specific rules:
// 1. Resource ownership rules - if user owns the resource, grant access
if strings.Contains(permission, ".own") || pm.isResourceOwner(ctx, userID, appID, permission) {
return true
}
// 2. Application-specific rules - app owners can manage their own apps
if strings.HasPrefix(permission, "app.") && pm.isAppOwner(ctx, userID, appID) {
return true
}
// 3. Token-specific rules - users can manage their own tokens
if strings.HasPrefix(permission, "token.") && pm.isTokenOwner(ctx, userID, appID, permission) {
return true
}
return false
}
// isResourceOwner checks if user owns the resource (placeholder implementation)
func (pm *PermissionManager) isResourceOwner(ctx context.Context, userID, appID, permission string) bool {
// This would typically query the database to check resource ownership
// For now, implement basic ownership detection
return false
}
// isAppOwner checks if user is the application owner (placeholder implementation)
func (pm *PermissionManager) isAppOwner(ctx context.Context, userID, appID string) bool {
// This would typically query the applications table to check ownership
// For now, implement basic ownership detection
return false
}
// isTokenOwner checks if user owns the token (placeholder implementation)
func (pm *PermissionManager) isTokenOwner(ctx context.Context, userID, appID, permission string) bool {
// This would typically query the tokens table to check ownership
// For now, implement basic ownership detection
return false
}

View File

@ -124,20 +124,11 @@ type VerifyResponse struct {
Error string `json:"error,omitempty"`
}
// TokenDeliveryMode specifies how tokens should be delivered in redirect flows
type TokenDeliveryMode string
const (
TokenDeliveryCookie TokenDeliveryMode = "cookie" // Token in secure cookie (default)
TokenDeliveryQuery TokenDeliveryMode = "query" // Token in query parameter (for integrations)
)
// LoginRequest represents a user login request
type LoginRequest struct {
AppID string `json:"app_id" validate:"required"`
Permissions []string `json:"permissions,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
TokenDelivery TokenDeliveryMode `json:"token_delivery,omitempty"` // How to deliver token in redirect flows
AppID string `json:"app_id" validate:"required"`
Permissions []string `json:"permissions,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
}
// LoginResponse represents a user login response

View File

@ -146,6 +146,28 @@ func (eh *ErrorHandler) HandleInternalError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, response)
}
// HandleNotFoundError handles resource not found errors
func (eh *ErrorHandler) HandleNotFoundError(c *gin.Context, resource string, message string) {
requestID := eh.getOrGenerateRequestID(c)
eh.logger.Warn("Resource not found",
zap.String("request_id", requestID),
zap.String("resource", resource),
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
zap.String("remote_addr", c.ClientIP()),
)
response := SecureErrorResponse{
Error: "resource_not_found",
Message: message,
RequestID: requestID,
Code: http.StatusNotFound,
}
c.JSON(http.StatusNotFound, response)
}
// determineErrorResponse determines the appropriate HTTP status and error type
func (eh *ErrorHandler) determineErrorResponse(err error) (int, string) {
if appErr, ok := err.(*AppError); ok {

View File

@ -55,8 +55,29 @@ func (h *ApplicationHandler) Create(c *gin.Context) {
return
}
// Validate input
validationErrors := h.validator.ValidateApplicationRequest(req.AppID, req.AppLink, req.CallbackURL, []string{})
// Validate input (skip permissions validation for application creation)
var validationErrors []validation.ValidationError
// Validate app ID
if result := h.validator.ValidateAppID(req.AppID); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
// Validate app link URL
if result := h.validator.ValidateURL(req.AppLink, "app_link"); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
// Validate callback URL
if result := h.validator.ValidateURL(req.CallbackURL, "callback_url"); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
// Validate token prefix if provided
if result := h.validator.ValidateTokenPrefix(req.TokenPrefix); !result.Valid {
validationErrors = append(validationErrors, result.Errors...)
}
if len(validationErrors) > 0 {
h.logger.Warn("Application validation failed",
zap.String("user_id", userID),

282
internal/handlers/audit.go Normal file
View File

@ -0,0 +1,282 @@
package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
"github.com/kms/api-key-service/internal/validation"
)
// AuditHandler handles audit-related HTTP requests
type AuditHandler struct {
auditLogger audit.AuditLogger
authService services.AuthenticationService
validator *validation.Validator
errorHandler *errors.ErrorHandler
logger *zap.Logger
}
// NewAuditHandler creates a new audit handler
func NewAuditHandler(
auditLogger audit.AuditLogger,
authService services.AuthenticationService,
logger *zap.Logger,
) *AuditHandler {
return &AuditHandler{
auditLogger: auditLogger,
authService: authService,
validator: validation.NewValidator(logger),
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
}
}
// AuditQueryRequest represents the request for querying audit events
type AuditQueryRequest struct {
EventTypes []string `json:"event_types,omitempty" form:"event_types"`
Statuses []string `json:"statuses,omitempty" form:"statuses"`
ActorID string `json:"actor_id,omitempty" form:"actor_id"`
ResourceID string `json:"resource_id,omitempty" form:"resource_id"`
ResourceType string `json:"resource_type,omitempty" form:"resource_type"`
StartTime *string `json:"start_time,omitempty" form:"start_time"`
EndTime *string `json:"end_time,omitempty" form:"end_time"`
Limit int `json:"limit,omitempty" form:"limit"`
Offset int `json:"offset,omitempty" form:"offset"`
OrderBy string `json:"order_by,omitempty" form:"order_by"`
OrderDesc *bool `json:"order_desc,omitempty" form:"order_desc"`
}
// AuditStatsRequest represents the request for audit statistics
type AuditStatsRequest struct {
EventTypes []string `json:"event_types,omitempty" form:"event_types"`
StartTime *string `json:"start_time,omitempty" form:"start_time"`
EndTime *string `json:"end_time,omitempty" form:"end_time"`
GroupBy string `json:"group_by,omitempty" form:"group_by"`
}
// AuditResponse represents the response structure for audit queries
type AuditResponse struct {
Events []AuditEventResponse `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// AuditEventResponse represents a single audit event in API responses
type AuditEventResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Timestamp string `json:"timestamp"`
ActorID string `json:"actor_id,omitempty"`
ActorIP string `json:"actor_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
Action string `json:"action"`
Description string `json:"description"`
Details map[string]interface{} `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
}
// ListEvents handles GET /audit/events
func (h *AuditHandler) ListEvents(c *gin.Context) {
// Parse query parameters
var req AuditQueryRequest
if err := c.ShouldBindQuery(&req); err != nil {
h.errorHandler.HandleValidationError(c, "query_params", "Invalid query parameters")
return
}
// Set defaults
if req.Limit <= 0 || req.Limit > 1000 {
req.Limit = 100
}
if req.Offset < 0 {
req.Offset = 0
}
if req.OrderBy == "" {
req.OrderBy = "timestamp"
}
if req.OrderDesc == nil {
orderDesc := true
req.OrderDesc = &orderDesc
}
// Convert request to audit filter
filter := &audit.AuditFilter{
ActorID: req.ActorID,
ResourceID: req.ResourceID,
ResourceType: req.ResourceType,
Limit: req.Limit,
Offset: req.Offset,
OrderBy: req.OrderBy,
OrderDesc: *req.OrderDesc,
}
// Convert event types
for _, et := range req.EventTypes {
filter.EventTypes = append(filter.EventTypes, audit.EventType(et))
}
// Convert statuses
for _, st := range req.Statuses {
filter.Statuses = append(filter.Statuses, audit.EventStatus(st))
}
// Parse time filters
if req.StartTime != nil && *req.StartTime != "" {
if startTime, err := time.Parse(time.RFC3339, *req.StartTime); err == nil {
filter.StartTime = &startTime
} else {
h.errorHandler.HandleValidationError(c, "start_time", "Invalid start_time format, use RFC3339")
return
}
}
if req.EndTime != nil && *req.EndTime != "" {
if endTime, err := time.Parse(time.RFC3339, *req.EndTime); err == nil {
filter.EndTime = &endTime
} else {
h.errorHandler.HandleValidationError(c, "end_time", "Invalid end_time format, use RFC3339")
return
}
}
// Query audit events
events, err := h.auditLogger.QueryEvents(c.Request.Context(), filter)
if err != nil {
h.logger.Error("Failed to query audit events", zap.Error(err))
h.errorHandler.HandleInternalError(c, err)
return
}
// Convert to response format
response := &AuditResponse{
Events: make([]AuditEventResponse, len(events)),
Total: len(events), // Note: This is just the count of returned events, not total matching
Limit: req.Limit,
Offset: req.Offset,
}
for i, event := range events {
response.Events[i] = AuditEventResponse{
ID: event.ID.String(),
Type: string(event.Type),
Status: string(event.Status),
Timestamp: event.Timestamp.Format(time.RFC3339),
ActorID: event.ActorID,
ActorIP: event.ActorIP,
UserAgent: event.UserAgent,
ResourceID: event.ResourceID,
ResourceType: event.ResourceType,
Action: event.Action,
Description: event.Description,
Details: event.Details,
RequestID: event.RequestID,
SessionID: event.SessionID,
}
}
c.JSON(http.StatusOK, response)
}
// GetEvent handles GET /audit/events/:id
func (h *AuditHandler) GetEvent(c *gin.Context) {
eventIDStr := c.Param("id")
eventID, err := uuid.Parse(eventIDStr)
if err != nil {
h.errorHandler.HandleValidationError(c, "id", "Invalid event ID format")
return
}
// Get the specific audit event
event, err := h.auditLogger.GetEventByID(c.Request.Context(), eventID)
if err != nil {
h.logger.Error("Failed to get audit event", zap.Error(err), zap.String("event_id", eventID.String()))
// Check if it's a not found error
if err.Error() == "audit event with ID '"+eventID.String()+"' not found" {
h.errorHandler.HandleNotFoundError(c, "audit_event", "Audit event not found")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
// Convert to response format
response := AuditEventResponse{
ID: event.ID.String(),
Type: string(event.Type),
Status: string(event.Status),
Timestamp: event.Timestamp.Format(time.RFC3339),
ActorID: event.ActorID,
ActorIP: event.ActorIP,
UserAgent: event.UserAgent,
ResourceID: event.ResourceID,
ResourceType: event.ResourceType,
Action: event.Action,
Description: event.Description,
Details: event.Details,
RequestID: event.RequestID,
SessionID: event.SessionID,
}
c.JSON(http.StatusOK, response)
}
// GetStats handles GET /audit/stats
func (h *AuditHandler) GetStats(c *gin.Context) {
// Parse query parameters
var req AuditStatsRequest
if err := c.ShouldBindQuery(&req); err != nil {
h.errorHandler.HandleValidationError(c, "query_params", "Invalid query parameters")
return
}
// Convert request to audit stats filter
filter := &audit.AuditStatsFilter{
GroupBy: req.GroupBy,
}
// Convert event types
for _, et := range req.EventTypes {
filter.EventTypes = append(filter.EventTypes, audit.EventType(et))
}
// Parse time filters
if req.StartTime != nil && *req.StartTime != "" {
if startTime, err := time.Parse(time.RFC3339, *req.StartTime); err == nil {
filter.StartTime = &startTime
} else {
h.errorHandler.HandleValidationError(c, "start_time", "Invalid start_time format, use RFC3339")
return
}
}
if req.EndTime != nil && *req.EndTime != "" {
if endTime, err := time.Parse(time.RFC3339, *req.EndTime); err == nil {
filter.EndTime = &endTime
} else {
h.errorHandler.HandleValidationError(c, "end_time", "Invalid end_time format, use RFC3339")
return
}
}
// Get audit statistics
stats, err := h.auditLogger.GetEventStats(c.Request.Context(), filter)
if err != nil {
h.logger.Error("Failed to get audit statistics", zap.Error(err))
h.errorHandler.HandleInternalError(c, err)
return
}
c.JSON(http.StatusOK, stats)
}

View File

@ -5,8 +5,11 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
@ -27,6 +30,17 @@ type AuthHandler struct {
config config.ConfigProvider
errorHandler *errors.ErrorHandler
logger *zap.Logger
loginTemplate *template.Template
}
// LoginPageData represents data passed to the login HTML template
type LoginPageData struct {
Token string
TokenJSON template.JS
RedirectURLJSON template.JS
ExpiresAt string
AppID string
UserID string
}
// NewAuthHandler creates a new auth handler
@ -36,6 +50,14 @@ func NewAuthHandler(
config config.ConfigProvider,
logger *zap.Logger,
) *AuthHandler {
// Load login template
templatePath := filepath.Join("templates", "login.html")
loginTemplate, err := template.ParseFiles(templatePath)
if err != nil {
logger.Error("Failed to load login template", zap.Error(err), zap.String("path", templatePath))
// Template loading failure is not fatal, we'll fall back to JSON
}
return &AuthHandler{
authService: authService,
tokenService: tokenService,
@ -43,21 +65,54 @@ func NewAuthHandler(
config: config,
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
loginTemplate: loginTemplate,
}
}
// Login handles POST /login
// Login handles login requests (both GET for HTML and POST for JSON)
func (h *AuthHandler) Login(c *gin.Context) {
// Handle GET requests or requests that prefer HTML
acceptHeader := c.GetHeader("Accept")
contentType := c.GetHeader("Content-Type")
isJSONRequest := (c.Request.Method == "POST" && (contentType == "application/json" ||
(acceptHeader != "" && (acceptHeader == "application/json" ||
(acceptHeader != "text/html" && acceptHeader != "*/*")))))
var req domain.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.errorHandler.HandleValidationError(c, "request_body", "Invalid login request format")
return
if isJSONRequest {
// Handle JSON POST request (existing API behavior)
if err := c.ShouldBindJSON(&req); err != nil {
h.errorHandler.HandleValidationError(c, "request_body", "Invalid login request format")
return
}
} else {
// Handle HTML request (GET or POST with form data)
req.AppID = c.Query("app_id")
req.RedirectURI = c.Query("redirect_uri")
// Parse permissions from query parameter (comma-separated)
if perms := c.Query("permissions"); perms != "" {
// Simple parsing for comma-separated permissions
req.Permissions = []string{perms} // Simplified for this example
}
// If no app_id provided, show error
if req.AppID == "" {
h.renderLoginError(c, "Missing required parameter: app_id", isJSONRequest)
return
}
}
// Validate authentication headers with HMAC signature
userContext, err := h.headerValidator.ValidateAuthenticationHeaders(c.Request)
if err != nil {
h.errorHandler.HandleAuthenticationError(c, err)
if isJSONRequest {
h.errorHandler.HandleAuthenticationError(c, err)
} else {
h.renderLoginError(c, "Authentication failed: "+err.Error(), isJSONRequest)
}
return
}
@ -66,12 +121,16 @@ func (h *AuthHandler) Login(c *gin.Context) {
// Generate user token
token, err := h.tokenService.GenerateUserToken(c.Request.Context(), req.AppID, userContext.UserID, req.Permissions)
if err != nil {
h.errorHandler.HandleInternalError(c, err)
if isJSONRequest {
h.errorHandler.HandleInternalError(c, err)
} else {
h.renderLoginError(c, "Failed to generate token: "+err.Error(), isJSONRequest)
}
return
}
if req.RedirectURI == "" {
// If no redirect URI, return token directly via secure response body
// For JSON requests without redirect URI, return token directly
if isJSONRequest && req.RedirectURI == "" {
c.JSON(http.StatusOK, gin.H{
"token": token,
"user_id": userContext.UserID,
@ -81,56 +140,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// For redirect flows, choose token delivery method
// Default to cookie delivery for security
tokenDelivery := req.TokenDelivery
if tokenDelivery == "" {
tokenDelivery = domain.TokenDeliveryCookie
}
h.logger.Debug("Token delivery mode", zap.String("mode", string(tokenDelivery)))
// Generate a secure state parameter for CSRF protection
state := h.generateSecureState(userContext.UserID, req.AppID)
// Handle redirect flows - always deliver token via query parameter
var redirectURL string
switch tokenDelivery {
case domain.TokenDeliveryQuery:
// Deliver token via query parameter (for integrations like VS Code)
if req.RedirectURI != "" {
// Generate a secure state parameter for CSRF protection
state := h.generateSecureState(userContext.UserID, req.AppID)
redirectURL = req.RedirectURI + "?token=" + token + "&state=" + state
case domain.TokenDeliveryCookie:
// Deliver token via secure cookie (default, more secure)
c.SetSameSite(http.SameSiteStrictMode)
// In development mode, make cookie accessible to JavaScript for testing
// In production, keep HTTP-only for security
httpOnly := !h.config.IsDevelopment()
secure := !h.config.IsDevelopment() // Only require HTTPS in production
c.SetCookie(
"auth_token", // name
token, // value
604800, // maxAge (7 days)
"/", // path
"", // domain (empty for current domain)
secure, // secure (HTTPS only in production)
httpOnly, // httpOnly (no JavaScript access in production)
)
// Redirect without token in URL for security
redirectURL = req.RedirectURI + "?state=" + state
default:
// Invalid delivery mode, default to cookie
redirectURL = req.RedirectURI + "?state=" + state
}
response := domain.LoginResponse{
RedirectURL: redirectURL,
// Return appropriate response format
if isJSONRequest {
response := domain.LoginResponse{
RedirectURL: redirectURL,
}
c.JSON(http.StatusOK, response)
} else {
// Render HTML page
h.renderLoginPage(c, token, redirectURL, userContext.UserID, req.AppID)
}
}
// renderLoginPage renders the HTML login page with token information
func (h *AuthHandler) renderLoginPage(c *gin.Context, token, redirectURL, userID, appID string) {
if h.loginTemplate == nil {
// Fallback to JSON if template not available
c.JSON(http.StatusOK, gin.H{
"token": token,
"redirect_url": redirectURL,
"user_id": userID,
"app_id": appID,
"message": "Login successful - HTML template not available",
})
return
}
c.JSON(http.StatusOK, response)
// Prepare template data
tokenJSON, _ := json.Marshal(token)
redirectURLJSON, _ := json.Marshal(redirectURL)
data := LoginPageData{
Token: token,
TokenJSON: template.JS(tokenJSON),
RedirectURLJSON: template.JS(redirectURLJSON),
ExpiresAt: time.Now().Add(7 * 24 * time.Hour).Format("Jan 2, 2006 at 3:04 PM MST"),
AppID: appID,
UserID: userID,
}
c.Header("Content-Type", "text/html; charset=utf-8")
// Override CSP for login page to allow inline styles and scripts
c.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
if err := h.loginTemplate.Execute(c.Writer, data); err != nil {
h.logger.Error("Failed to render login template", zap.Error(err))
// Fallback to JSON response
c.JSON(http.StatusOK, gin.H{
"token": token,
"redirect_url": redirectURL,
"user_id": userID,
"app_id": appID,
"message": "Login successful - template render failed",
})
}
}
// renderLoginError renders an error page or JSON error response
func (h *AuthHandler) renderLoginError(c *gin.Context, message string, isJSON bool) {
if isJSON {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": message,
})
return
}
// Simple HTML error page
c.Header("Content-Type", "text/html; charset=utf-8")
// Override CSP for error page to allow inline styles
c.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'")
c.String(http.StatusBadRequest, `
<!DOCTYPE html>
<html>
<head>
<title>Login Error</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.error { background: #f8d7da; color: #721c24; padding: 15px; border-radius: 5px; }
</style>
</head>
<body>
<h1>Login Error</h1>
<div class="error">%s</div>
<p><a href="javascript:history.back()">Go back</a></p>
</body>
</html>`, message)
}
// generateSecureState generates a secure state parameter for OAuth flows

View File

@ -9,13 +9,17 @@ import (
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/errors"
"github.com/kms/api-key-service/internal/services"
"github.com/kms/api-key-service/internal/validation"
)
// TokenHandler handles token-related HTTP requests
type TokenHandler struct {
tokenService services.TokenService
authService services.AuthenticationService
validator *validation.Validator
errorHandler *errors.ErrorHandler
logger *zap.Logger
}
@ -28,96 +32,139 @@ func NewTokenHandler(
return &TokenHandler{
tokenService: tokenService,
authService: authService,
validator: validation.NewValidator(logger),
errorHandler: errors.NewErrorHandler(logger),
logger: logger,
}
}
// Create handles POST /applications/:id/tokens
func (h *TokenHandler) Create(c *gin.Context) {
// Validate application ID parameter
appID := c.Param("id")
if appID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Application ID is required",
})
h.errorHandler.HandleValidationError(c, "id", "Application ID is required")
return
}
// Bind and validate JSON request
var req domain.CreateStaticTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("Invalid request body", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid request body: " + err.Error(),
})
h.errorHandler.HandleValidationError(c, "request_body", "Invalid request body format")
return
}
// Set app ID from URL parameter
req.AppID = appID
// Basic validation - the service layer will do more comprehensive validation
if req.AppID == "" {
h.errorHandler.HandleValidationError(c, "app_id", "Application ID is required")
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
h.logger.Error("User ID not found in context")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Authentication context not found",
})
h.errorHandler.HandleAuthenticationError(c, errors.NewAuthenticationError("Authentication context not found"))
return
}
token, err := h.tokenService.CreateStaticToken(c.Request.Context(), &req, userID.(string))
userIDStr, ok := userID.(string)
if !ok {
h.logger.Error("Invalid user ID type in context", zap.Any("user_id", userID))
h.errorHandler.HandleInternalError(c, errors.NewInternalError("Invalid authentication context"))
return
}
// Create the token
token, err := h.tokenService.CreateStaticToken(c.Request.Context(), &req, userIDStr)
if err != nil {
h.logger.Error("Failed to create token", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to create token",
})
h.logger.Error("Failed to create token",
zap.Error(err),
zap.String("app_id", appID),
zap.String("user_id", userIDStr))
// Handle different types of errors appropriately
if errors.IsNotFound(err) {
h.errorHandler.HandleError(c, err, "Application not found")
} else if errors.IsValidationError(err) {
h.errorHandler.HandleValidationError(c, "token", "Token creation validation failed")
} else if errors.IsAuthorizationError(err) {
h.errorHandler.HandleAuthorizationError(c, "token_creation")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
h.logger.Info("Token created", zap.String("token_id", token.ID.String()))
h.logger.Info("Token created successfully",
zap.String("token_id", token.ID.String()),
zap.String("app_id", appID),
zap.String("user_id", userIDStr))
c.JSON(http.StatusCreated, token)
}
// ListByApp handles GET /applications/:id/tokens
func (h *TokenHandler) ListByApp(c *gin.Context) {
// Validate application ID parameter
appID := c.Param("id")
if appID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Application ID is required",
})
h.errorHandler.HandleValidationError(c, "id", "Application ID is required")
return
}
// Parse pagination parameters
// Parse and validate pagination parameters
limit := 50
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 1000 {
limit = parsed
} else if parsed <= 0 || parsed > 1000 {
h.errorHandler.HandleValidationError(c, "limit", "Limit must be between 1 and 1000")
return
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
} else if parsed < 0 {
h.errorHandler.HandleValidationError(c, "offset", "Offset must be non-negative")
return
}
}
// List tokens
tokens, err := h.tokenService.ListByApp(c.Request.Context(), appID, limit, offset)
if err != nil {
h.logger.Error("Failed to list tokens", zap.Error(err), zap.String("app_id", appID))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to list tokens",
})
h.logger.Error("Failed to list tokens",
zap.Error(err),
zap.String("app_id", appID),
zap.Int("limit", limit),
zap.Int("offset", offset))
// Handle different types of errors appropriately
if errors.IsNotFound(err) {
h.errorHandler.HandleNotFoundError(c, "application", "Application not found")
} else if errors.IsAuthorizationError(err) {
h.errorHandler.HandleAuthorizationError(c, "token_list")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
h.logger.Debug("Tokens listed successfully",
zap.String("app_id", appID),
zap.Int("token_count", len(tokens)),
zap.Int("limit", limit),
zap.Int("offset", offset))
c.JSON(http.StatusOK, gin.H{
"data": tokens,
"limit": limit,
@ -128,21 +175,17 @@ func (h *TokenHandler) ListByApp(c *gin.Context) {
// Delete handles DELETE /tokens/:id
func (h *TokenHandler) Delete(c *gin.Context) {
// Validate token ID parameter
tokenIDStr := c.Param("id")
if tokenIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Token ID is required",
})
h.errorHandler.HandleValidationError(c, "id", "Token ID is required")
return
}
tokenID, err := uuid.Parse(tokenIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid token ID format",
})
h.logger.Warn("Invalid token ID format", zap.String("token_id", tokenIDStr), zap.Error(err))
h.errorHandler.HandleValidationError(c, "id", "Invalid token ID format")
return
}
@ -150,23 +193,39 @@ func (h *TokenHandler) Delete(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
h.logger.Error("User ID not found in context")
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Authentication context not found",
})
h.errorHandler.HandleAuthenticationError(c, errors.NewAuthenticationError("Authentication context not found"))
return
}
err = h.tokenService.Delete(c.Request.Context(), tokenID, userID.(string))
userIDStr, ok := userID.(string)
if !ok {
h.logger.Error("Invalid user ID type in context", zap.Any("user_id", userID))
h.errorHandler.HandleInternalError(c, errors.NewInternalError("Invalid authentication context"))
return
}
// Delete the token
err = h.tokenService.Delete(c.Request.Context(), tokenID, userIDStr)
if err != nil {
h.logger.Error("Failed to delete token", zap.Error(err), zap.String("token_id", tokenID.String()))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal Server Error",
"message": "Failed to delete token",
})
h.logger.Error("Failed to delete token",
zap.Error(err),
zap.String("token_id", tokenID.String()),
zap.String("user_id", userIDStr))
// Handle different types of errors appropriately
if errors.IsNotFound(err) {
h.errorHandler.HandleNotFoundError(c, "token", "Token not found")
} else if errors.IsAuthorizationError(err) {
h.errorHandler.HandleAuthorizationError(c, "token_deletion")
} else {
h.errorHandler.HandleInternalError(c, err)
}
return
}
h.logger.Info("Token deleted", zap.String("token_id", tokenID.String()))
h.logger.Info("Token deleted successfully",
zap.String("token_id", tokenID.String()),
zap.String("user_id", userIDStr))
c.JSON(http.StatusNoContent, nil)
}

View File

@ -5,6 +5,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/domain"
)
@ -319,3 +320,33 @@ type MetricsProvider interface {
// RecordDuration records the duration of an operation
RecordDuration(ctx context.Context, name string, duration time.Duration, labels map[string]string)
}
// AuditRepository defines the interface for audit event storage operations
type AuditRepository interface {
// Create stores a new audit event
Create(ctx context.Context, event *audit.AuditEvent) error
// Query retrieves audit events based on filter criteria
Query(ctx context.Context, filter *audit.AuditFilter) ([]*audit.AuditEvent, error)
// GetStats returns aggregated statistics for audit events
GetStats(ctx context.Context, filter *audit.AuditStatsFilter) (*audit.AuditStats, error)
// DeleteOldEvents removes audit events older than the specified time
DeleteOldEvents(ctx context.Context, olderThan time.Time) (int, error)
// GetByID retrieves a specific audit event by its ID
GetByID(ctx context.Context, eventID uuid.UUID) (*audit.AuditEvent, error)
// GetByRequestID retrieves all audit events for a specific request
GetByRequestID(ctx context.Context, requestID string) ([]*audit.AuditEvent, error)
// GetBySession retrieves all audit events for a specific session
GetBySession(ctx context.Context, sessionID string) ([]*audit.AuditEvent, error)
// GetByActor retrieves audit events for a specific actor
GetByActor(ctx context.Context, actorID string, limit, offset int) ([]*audit.AuditEvent, error)
// GetByResource retrieves audit events for a specific resource
GetByResource(ctx context.Context, resourceType, resourceID string, limit, offset int) ([]*audit.AuditEvent, error)
}

View File

@ -0,0 +1,742 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/repository"
)
// AuditRepository implements the AuditRepository interface for PostgreSQL
type AuditRepository struct {
db repository.DatabaseProvider
}
// NewAuditRepository creates a new PostgreSQL audit repository
func NewAuditRepository(db repository.DatabaseProvider) repository.AuditRepository {
return &AuditRepository{db: db}
}
// Create stores a new audit event
func (r *AuditRepository) Create(ctx context.Context, event *audit.AuditEvent) error {
query := `
INSERT INTO audit_events (
id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19
)
`
db := r.db.GetDB().(*sql.DB)
// Ensure event has an ID and timestamp
if event.ID == uuid.Nil {
event.ID = uuid.New()
}
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
// Convert details to JSON
var detailsJSON []byte
var err error
if event.Details != nil {
detailsJSON, err = json.Marshal(event.Details)
if err != nil {
return fmt.Errorf("failed to marshal event details: %w", err)
}
} else {
detailsJSON = []byte("{}")
}
// Convert metadata to JSON
var metadataJSON []byte
if event.Metadata != nil {
metadataJSON, err = json.Marshal(event.Metadata)
if err != nil {
return fmt.Errorf("failed to marshal event metadata: %w", err)
}
} else {
metadataJSON = []byte("{}")
}
// Handle nullable fields
var actorID, actorType, actorIP, userAgent *string
var tenantID *uuid.UUID
var resourceID, resourceType *string
var requestID, sessionID *string
if event.ActorID != "" {
actorID = &event.ActorID
}
if event.ActorType != "" {
actorType = &event.ActorType
}
if event.ActorIP != "" {
actorIP = &event.ActorIP
}
if event.UserAgent != "" {
userAgent = &event.UserAgent
}
if event.TenantID != nil {
tenantID = event.TenantID
}
if event.ResourceID != "" {
resourceID = &event.ResourceID
}
if event.ResourceType != "" {
resourceType = &event.ResourceType
}
if event.RequestID != "" {
requestID = &event.RequestID
}
if event.SessionID != "" {
sessionID = &event.SessionID
}
_, err = db.ExecContext(ctx, query,
event.ID,
string(event.Type),
string(event.Severity),
string(event.Status),
event.Timestamp,
actorID,
actorType,
actorIP,
userAgent,
tenantID,
resourceID,
resourceType,
event.Action,
event.Description,
string(detailsJSON),
requestID,
sessionID,
pq.Array(event.Tags),
string(metadataJSON),
)
if err != nil {
return fmt.Errorf("failed to create audit event: %w", err)
}
return nil
}
// Query retrieves audit events based on filter criteria
func (r *AuditRepository) Query(ctx context.Context, filter *audit.AuditFilter) ([]*audit.AuditEvent, error) {
// Build dynamic query with filters
var conditions []string
var args []interface{}
argIndex := 1
baseQuery := `
SELECT id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
FROM audit_events
`
// Add filters
if len(filter.EventTypes) > 0 {
conditions = append(conditions, fmt.Sprintf("type = ANY($%d)", argIndex))
typeStrings := make([]string, len(filter.EventTypes))
for i, t := range filter.EventTypes {
typeStrings[i] = string(t)
}
args = append(args, pq.Array(typeStrings))
argIndex++
}
if len(filter.Severities) > 0 {
conditions = append(conditions, fmt.Sprintf("severity = ANY($%d)", argIndex))
severityStrings := make([]string, len(filter.Severities))
for i, s := range filter.Severities {
severityStrings[i] = string(s)
}
args = append(args, pq.Array(severityStrings))
argIndex++
}
if len(filter.Statuses) > 0 {
conditions = append(conditions, fmt.Sprintf("status = ANY($%d)", argIndex))
statusStrings := make([]string, len(filter.Statuses))
for i, s := range filter.Statuses {
statusStrings[i] = string(s)
}
args = append(args, pq.Array(statusStrings))
argIndex++
}
if filter.ActorID != "" {
conditions = append(conditions, fmt.Sprintf("actor_id = $%d", argIndex))
args = append(args, filter.ActorID)
argIndex++
}
if filter.ActorType != "" {
conditions = append(conditions, fmt.Sprintf("actor_type = $%d", argIndex))
args = append(args, filter.ActorType)
argIndex++
}
if filter.TenantID != nil {
conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIndex))
args = append(args, *filter.TenantID)
argIndex++
}
if filter.ResourceID != "" {
conditions = append(conditions, fmt.Sprintf("resource_id = $%d", argIndex))
args = append(args, filter.ResourceID)
argIndex++
}
if filter.ResourceType != "" {
conditions = append(conditions, fmt.Sprintf("resource_type = $%d", argIndex))
args = append(args, filter.ResourceType)
argIndex++
}
if filter.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("timestamp >= $%d", argIndex))
args = append(args, *filter.StartTime)
argIndex++
}
if filter.EndTime != nil {
conditions = append(conditions, fmt.Sprintf("timestamp <= $%d", argIndex))
args = append(args, *filter.EndTime)
argIndex++
}
if len(filter.Tags) > 0 {
conditions = append(conditions, fmt.Sprintf("tags && $%d", argIndex))
args = append(args, pq.Array(filter.Tags))
argIndex++
}
// Build WHERE clause
if len(conditions) > 0 {
baseQuery += " WHERE " + strings.Join(conditions, " AND ")
}
// Add ORDER BY
orderBy := "timestamp"
if filter.OrderBy != "" {
switch filter.OrderBy {
case "timestamp", "type", "severity", "status":
orderBy = filter.OrderBy
}
}
direction := "DESC"
if !filter.OrderDesc {
direction = "ASC"
}
baseQuery += fmt.Sprintf(" ORDER BY %s %s", orderBy, direction)
// Add pagination
if filter.Limit <= 0 {
filter.Limit = 100
}
if filter.Limit > 1000 {
filter.Limit = 1000
}
baseQuery += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, filter.Limit)
argIndex++
if filter.Offset > 0 {
baseQuery += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, filter.Offset)
}
db := r.db.GetDB().(*sql.DB)
rows, err := db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to query audit events: %w", err)
}
defer rows.Close()
var events []*audit.AuditEvent
for rows.Next() {
event, err := r.scanAuditEvent(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan audit event: %w", err)
}
events = append(events, event)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating audit events: %w", err)
}
return events, nil
}
// GetStats returns aggregated statistics for audit events
func (r *AuditRepository) GetStats(ctx context.Context, filter *audit.AuditStatsFilter) (*audit.AuditStats, error) {
stats := &audit.AuditStats{
ByType: make(map[audit.EventType]int),
BySeverity: make(map[audit.EventSeverity]int),
ByStatus: make(map[audit.EventStatus]int),
}
// Build base conditions
var conditions []string
var args []interface{}
argIndex := 1
if len(filter.EventTypes) > 0 {
conditions = append(conditions, fmt.Sprintf("type = ANY($%d)", argIndex))
typeStrings := make([]string, len(filter.EventTypes))
for i, t := range filter.EventTypes {
typeStrings[i] = string(t)
}
args = append(args, pq.Array(typeStrings))
argIndex++
}
if filter.TenantID != nil {
conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIndex))
args = append(args, *filter.TenantID)
argIndex++
}
if filter.StartTime != nil {
conditions = append(conditions, fmt.Sprintf("timestamp >= $%d", argIndex))
args = append(args, *filter.StartTime)
argIndex++
}
if filter.EndTime != nil {
conditions = append(conditions, fmt.Sprintf("timestamp <= $%d", argIndex))
args = append(args, *filter.EndTime)
argIndex++
}
whereClause := ""
if len(conditions) > 0 {
whereClause = "WHERE " + strings.Join(conditions, " AND ")
}
db := r.db.GetDB().(*sql.DB)
// Get total count
totalQuery := fmt.Sprintf("SELECT COUNT(*) FROM audit_events %s", whereClause)
err := db.QueryRowContext(ctx, totalQuery, args...).Scan(&stats.TotalEvents)
if err != nil {
return nil, fmt.Errorf("failed to get total event count: %w", err)
}
// Get stats by type
typeQuery := fmt.Sprintf(`
SELECT type, COUNT(*)
FROM audit_events %s
GROUP BY type
ORDER BY COUNT(*) DESC
`, whereClause)
rows, err := db.QueryContext(ctx, typeQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to get type stats: %w", err)
}
defer rows.Close()
for rows.Next() {
var eventType string
var count int
if err := rows.Scan(&eventType, &count); err != nil {
return nil, fmt.Errorf("failed to scan type stats: %w", err)
}
stats.ByType[audit.EventType(eventType)] = count
}
// Get stats by severity
severityQuery := fmt.Sprintf(`
SELECT severity, COUNT(*)
FROM audit_events %s
GROUP BY severity
ORDER BY COUNT(*) DESC
`, whereClause)
rows, err = db.QueryContext(ctx, severityQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to get severity stats: %w", err)
}
defer rows.Close()
for rows.Next() {
var severity string
var count int
if err := rows.Scan(&severity, &count); err != nil {
return nil, fmt.Errorf("failed to scan severity stats: %w", err)
}
stats.BySeverity[audit.EventSeverity(severity)] = count
}
// Get stats by status
statusQuery := fmt.Sprintf(`
SELECT status, COUNT(*)
FROM audit_events %s
GROUP BY status
ORDER BY COUNT(*) DESC
`, whereClause)
rows, err = db.QueryContext(ctx, statusQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to get status stats: %w", err)
}
defer rows.Close()
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, fmt.Errorf("failed to scan status stats: %w", err)
}
stats.ByStatus[audit.EventStatus(status)] = count
}
// Get time-based stats if requested
if filter.GroupBy != "" {
stats.ByTime = make(map[string]int)
var timeFormat string
switch filter.GroupBy {
case "hour":
timeFormat = "YYYY-MM-DD HH24:00"
case "day":
timeFormat = "YYYY-MM-DD"
default:
timeFormat = "YYYY-MM-DD"
}
timeQuery := fmt.Sprintf(`
SELECT TO_CHAR(timestamp, '%s') as time_group, COUNT(*)
FROM audit_events %s
GROUP BY time_group
ORDER BY time_group DESC
`, timeFormat, whereClause)
rows, err = db.QueryContext(ctx, timeQuery, args...)
if err != nil {
return nil, fmt.Errorf("failed to get time stats: %w", err)
}
defer rows.Close()
for rows.Next() {
var timeGroup string
var count int
if err := rows.Scan(&timeGroup, &count); err != nil {
return nil, fmt.Errorf("failed to scan time stats: %w", err)
}
stats.ByTime[timeGroup] = count
}
}
return stats, nil
}
// DeleteOldEvents removes audit events older than the specified time
func (r *AuditRepository) DeleteOldEvents(ctx context.Context, olderThan time.Time) (int, error) {
query := `DELETE FROM audit_events WHERE timestamp < $1`
db := r.db.GetDB().(*sql.DB)
result, err := db.ExecContext(ctx, query, olderThan)
if err != nil {
return 0, fmt.Errorf("failed to delete old audit events: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to get rows affected: %w", err)
}
return int(rowsAffected), nil
}
// GetByID retrieves a specific audit event by its ID
func (r *AuditRepository) GetByID(ctx context.Context, eventID uuid.UUID) (*audit.AuditEvent, error) {
query := `
SELECT id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
FROM audit_events
WHERE id = $1
`
db := r.db.GetDB().(*sql.DB)
row := db.QueryRowContext(ctx, query, eventID)
event, err := r.scanAuditEvent(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("audit event with ID '%s' not found", eventID)
}
return nil, fmt.Errorf("failed to get audit event: %w", err)
}
return event, nil
}
// GetByRequestID retrieves all audit events for a specific request
func (r *AuditRepository) GetByRequestID(ctx context.Context, requestID string) ([]*audit.AuditEvent, error) {
query := `
SELECT id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
FROM audit_events
WHERE request_id = $1
ORDER BY timestamp ASC
`
db := r.db.GetDB().(*sql.DB)
rows, err := db.QueryContext(ctx, query, requestID)
if err != nil {
return nil, fmt.Errorf("failed to query audit events by request ID: %w", err)
}
defer rows.Close()
var events []*audit.AuditEvent
for rows.Next() {
event, err := r.scanAuditEvent(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan audit event: %w", err)
}
events = append(events, event)
}
return events, nil
}
// GetBySession retrieves all audit events for a specific session
func (r *AuditRepository) GetBySession(ctx context.Context, sessionID string) ([]*audit.AuditEvent, error) {
query := `
SELECT id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
FROM audit_events
WHERE session_id = $1
ORDER BY timestamp ASC
`
db := r.db.GetDB().(*sql.DB)
rows, err := db.QueryContext(ctx, query, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to query audit events by session ID: %w", err)
}
defer rows.Close()
var events []*audit.AuditEvent
for rows.Next() {
event, err := r.scanAuditEvent(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan audit event: %w", err)
}
events = append(events, event)
}
return events, nil
}
// GetByActor retrieves audit events for a specific actor
func (r *AuditRepository) GetByActor(ctx context.Context, actorID string, limit, offset int) ([]*audit.AuditEvent, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
query := `
SELECT id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
FROM audit_events
WHERE actor_id = $1
ORDER BY timestamp DESC
LIMIT $2 OFFSET $3
`
db := r.db.GetDB().(*sql.DB)
rows, err := db.QueryContext(ctx, query, actorID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to query audit events by actor: %w", err)
}
defer rows.Close()
var events []*audit.AuditEvent
for rows.Next() {
event, err := r.scanAuditEvent(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan audit event: %w", err)
}
events = append(events, event)
}
return events, nil
}
// GetByResource retrieves audit events for a specific resource
func (r *AuditRepository) GetByResource(ctx context.Context, resourceType, resourceID string, limit, offset int) ([]*audit.AuditEvent, error) {
if limit <= 0 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
query := `
SELECT id, type, severity, status, timestamp,
actor_id, actor_type, actor_ip, user_agent, tenant_id,
resource_id, resource_type, action, description, details,
request_id, session_id, tags, metadata
FROM audit_events
WHERE resource_type = $1 AND resource_id = $2
ORDER BY timestamp DESC
LIMIT $3 OFFSET $4
`
db := r.db.GetDB().(*sql.DB)
rows, err := db.QueryContext(ctx, query, resourceType, resourceID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to query audit events by resource: %w", err)
}
defer rows.Close()
var events []*audit.AuditEvent
for rows.Next() {
event, err := r.scanAuditEvent(rows)
if err != nil {
return nil, fmt.Errorf("failed to scan audit event: %w", err)
}
events = append(events, event)
}
return events, nil
}
// scanAuditEvent scans a database row into an AuditEvent struct
func (r *AuditRepository) scanAuditEvent(row interface{}) (*audit.AuditEvent, error) {
event := &audit.AuditEvent{}
var typeStr, severityStr, statusStr string
var actorID, actorType, actorIP, userAgent sql.NullString
var tenantID *uuid.UUID
var resourceID, resourceType sql.NullString
var detailsJSON, metadataJSON string
var requestID, sessionID sql.NullString
var tags pq.StringArray
var scanner interface {
Scan(dest ...interface{}) error
}
switch v := row.(type) {
case *sql.Row:
scanner = v
case *sql.Rows:
scanner = v
default:
return nil, fmt.Errorf("invalid row type")
}
err := scanner.Scan(
&event.ID,
&typeStr,
&severityStr,
&statusStr,
&event.Timestamp,
&actorID,
&actorType,
&actorIP,
&userAgent,
&tenantID,
&resourceID,
&resourceType,
&event.Action,
&event.Description,
&detailsJSON,
&requestID,
&sessionID,
&tags,
&metadataJSON,
)
if err != nil {
return nil, err
}
// Convert string enums to types
event.Type = audit.EventType(typeStr)
event.Severity = audit.EventSeverity(severityStr)
event.Status = audit.EventStatus(statusStr)
// Handle nullable fields
if actorID.Valid {
event.ActorID = actorID.String
}
if actorType.Valid {
event.ActorType = actorType.String
}
if actorIP.Valid {
event.ActorIP = actorIP.String
}
if userAgent.Valid {
event.UserAgent = userAgent.String
}
if tenantID != nil {
event.TenantID = tenantID
}
if resourceID.Valid {
event.ResourceID = resourceID.String
}
if resourceType.Valid {
event.ResourceType = resourceType.String
}
if requestID.Valid {
event.RequestID = requestID.String
}
if sessionID.Valid {
event.SessionID = sessionID.String
}
// Convert tags
event.Tags = []string(tags)
// Parse JSON fields
if detailsJSON != "" {
if err := json.Unmarshal([]byte(detailsJSON), &event.Details); err != nil {
return nil, fmt.Errorf("failed to unmarshal details JSON: %w", err)
}
}
if metadataJSON != "" {
if err := json.Unmarshal([]byte(metadataJSON), &event.Metadata); err != nil {
return nil, fmt.Errorf("failed to unmarshal metadata JSON: %w", err)
}
}
return event, nil
}

View File

@ -5,30 +5,66 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/repository"
)
// applicationService implements the ApplicationService interface
type applicationService struct {
appRepo repository.ApplicationRepository
logger *zap.Logger
validator *validator.Validate
appRepo repository.ApplicationRepository
auditRepo repository.AuditRepository
auditLogger audit.AuditLogger
logger *zap.Logger
validator *validator.Validate
}
// NewApplicationService creates a new application service
func NewApplicationService(appRepo repository.ApplicationRepository, logger *zap.Logger) ApplicationService {
func NewApplicationService(appRepo repository.ApplicationRepository, auditRepo repository.AuditRepository, logger *zap.Logger) ApplicationService {
// Create audit logger with audit package's repository interface
auditRepoImpl := &auditRepositoryAdapter{repo: auditRepo}
auditLogger := audit.NewAuditLogger(nil, logger, auditRepoImpl) // config can be nil for now
return &applicationService{
appRepo: appRepo,
logger: logger,
validator: validator.New(),
appRepo: appRepo,
auditRepo: auditRepo,
auditLogger: auditLogger,
logger: logger,
validator: validator.New(),
}
}
// auditRepositoryAdapter adapts repository.AuditRepository to audit.AuditRepository
type auditRepositoryAdapter struct {
repo repository.AuditRepository
}
func (a *auditRepositoryAdapter) Create(ctx context.Context, event *audit.AuditEvent) error {
return a.repo.Create(ctx, event)
}
func (a *auditRepositoryAdapter) Query(ctx context.Context, filter *audit.AuditFilter) ([]*audit.AuditEvent, error) {
return a.repo.Query(ctx, filter)
}
func (a *auditRepositoryAdapter) GetStats(ctx context.Context, filter *audit.AuditStatsFilter) (*audit.AuditStats, error) {
return a.repo.GetStats(ctx, filter)
}
func (a *auditRepositoryAdapter) DeleteOldEvents(ctx context.Context, olderThan time.Time) (int, error) {
return a.repo.DeleteOldEvents(ctx, olderThan)
}
func (a *auditRepositoryAdapter) GetByID(ctx context.Context, eventID uuid.UUID) (*audit.AuditEvent, error) {
return a.repo.GetByID(ctx, eventID)
}
// Create creates a new application
func (s *applicationService) Create(ctx context.Context, req *domain.CreateApplicationRequest, userID string) (*domain.Application, error) {
s.logger.Info("Creating application", zap.String("app_id", req.AppID), zap.String("user_id", userID))
@ -75,9 +111,43 @@ func (s *applicationService) Create(ctx context.Context, req *domain.CreateAppli
if err := s.appRepo.Create(ctx, app); err != nil {
s.logger.Error("Failed to create application", zap.Error(err), zap.String("app_id", req.AppID))
// Log audit event for failed creation
s.auditLogger.LogEvent(ctx, audit.NewAuditEventBuilder(audit.EventTypeAppCreated).
WithSeverity(audit.SeverityError).
WithStatus(audit.StatusFailure).
WithActor(userID, "user", "").
WithResource(req.AppID, "application").
WithAction("create").
WithDescription(fmt.Sprintf("Failed to create application %s", req.AppID)).
WithDetails(map[string]interface{}{
"error": err.Error(),
"app_id": req.AppID,
"user_id": userID,
}).
Build())
return nil, fmt.Errorf("failed to create application: %w", err)
}
// Log successful creation
s.auditLogger.LogEvent(ctx, audit.NewAuditEventBuilder(audit.EventTypeAppCreated).
WithSeverity(audit.SeverityInfo).
WithStatus(audit.StatusSuccess).
WithActor(userID, "user", "").
WithResource(app.AppID, "application").
WithAction("create").
WithDescription(fmt.Sprintf("Created application %s", app.AppID)).
WithDetails(map[string]interface{}{
"app_id": app.AppID,
"app_link": app.AppLink,
"type": app.Type,
"user_id": userID,
"owner_name": app.Owner.Name,
"owner_type": app.Owner.Type,
}).
Build())
s.logger.Info("Application created successfully", zap.String("app_id", app.AppID))
return app, nil
}

View File

@ -1,5 +1,5 @@
# Multi-stage build for React frontend
FROM node:18-alpine AS builder
FROM node:24-alpine AS builder
WORKDIR /app
@ -16,7 +16,7 @@ COPY . .
RUN npm run build
# Production stage with nginx
FROM nginx:alpine
FROM docker.io/library/nginx:alpine
# Copy built application
COPY --from=builder /app/build /usr/share/nginx/html

View File

@ -1,3 +1,5 @@
error_log /var/log/nginx/error.log warn;
server {
listen 80;
server_name localhost;

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Table,
Card,
@ -28,6 +28,7 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { apiService, AuditEvent, AuditQueryParams } from '../services/apiService';
dayjs.extend(relativeTime);
@ -35,103 +36,10 @@ const { Title, Text } = Typography;
const { RangePicker } = DatePicker;
const { Option } = Select;
interface AuditLogEntry {
id: string;
timestamp: string;
user_id: string;
action: string;
resource_type: string;
resource_id: string;
status: 'success' | 'failure' | 'warning';
ip_address: string;
user_agent: string;
details: Record<string, any>;
}
// Mock audit data for demonstration
const mockAuditData: AuditLogEntry[] = [
{
id: '1',
timestamp: dayjs().subtract(1, 'hour').toISOString(),
user_id: 'admin@example.com',
action: 'CREATE_APPLICATION',
resource_type: 'application',
resource_id: 'com.example.newapp',
status: 'success',
ip_address: '192.168.1.100',
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
details: {
app_link: 'https://newapp.example.com',
owner: 'Development Team'
}
},
{
id: '2',
timestamp: dayjs().subtract(2, 'hours').toISOString(),
user_id: 'user@example.com',
action: 'CREATE_TOKEN',
resource_type: 'token',
resource_id: 'token-abc123',
status: 'success',
ip_address: '192.168.1.101',
user_agent: 'curl/7.68.0',
details: {
app_id: 'com.example.app',
permissions: ['repo.read', 'repo.write']
}
},
{
id: '3',
timestamp: dayjs().subtract(3, 'hours').toISOString(),
user_id: 'admin@example.com',
action: 'DELETE_TOKEN',
resource_type: 'token',
resource_id: 'token-xyz789',
status: 'success',
ip_address: '192.168.1.100',
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
details: {
app_id: 'com.example.oldapp',
reason: 'Token compromised'
}
},
{
id: '4',
timestamp: dayjs().subtract(4, 'hours').toISOString(),
user_id: 'user@example.com',
action: 'VERIFY_TOKEN',
resource_type: 'token',
resource_id: 'token-def456',
status: 'failure',
ip_address: '192.168.1.102',
user_agent: 'PostmanRuntime/7.28.4',
details: {
app_id: 'com.example.app',
error: 'Token expired'
}
},
{
id: '5',
timestamp: dayjs().subtract(6, 'hours').toISOString(),
user_id: 'admin@example.com',
action: 'UPDATE_APPLICATION',
resource_type: 'application',
resource_id: 'com.example.app',
status: 'success',
ip_address: '192.168.1.100',
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
details: {
changes: {
callback_url: 'https://updated.example.com/callback'
}
}
},
];
const Audit: React.FC = () => {
const [auditData, setAuditData] = useState<AuditLogEntry[]>(mockAuditData);
const [filteredData, setFilteredData] = useState<AuditLogEntry[]>(mockAuditData);
const [loading, setLoading] = useState(false);
const [auditData, setAuditData] = useState<AuditEvent[]>([]);
const [filteredData, setFilteredData] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
dateRange: null as any,
action: '',
@ -140,36 +48,70 @@ const Audit: React.FC = () => {
resourceType: '',
});
const applyFilters = () => {
let filtered = [...auditData];
// Load audit data on component mount
useEffect(() => {
loadAuditData();
}, []);
if (filters.dateRange && filters.dateRange.length === 2) {
const [start, end] = filters.dateRange;
filtered = filtered.filter(entry => {
const entryDate = dayjs(entry.timestamp);
return entryDate.isAfter(start) && entryDate.isBefore(end);
const loadAuditData = async () => {
try {
setLoading(true);
const response = await apiService.getAuditEvents({
limit: 100,
order_by: 'timestamp',
order_desc: true,
});
setAuditData(response.events);
setFilteredData(response.events);
} catch (error) {
console.error('Failed to load audit data:', error);
// Keep empty arrays on error
setAuditData([]);
setFilteredData([]);
} finally {
setLoading(false);
}
};
if (filters.action) {
filtered = filtered.filter(entry => entry.action === filters.action);
const applyFilters = async () => {
// For real-time filtering, we'll use the API with filters
try {
setLoading(true);
const params: AuditQueryParams = {
limit: 100,
order_by: 'timestamp',
order_desc: true,
};
if (filters.dateRange && filters.dateRange.length === 2) {
const [start, end] = filters.dateRange;
params.start_time = start.toISOString();
params.end_time = end.toISOString();
}
if (filters.action) {
params.event_types = [filters.action];
}
if (filters.status) {
params.statuses = [filters.status];
}
if (filters.user) {
params.actor_id = filters.user;
}
if (filters.resourceType) {
params.resource_type = filters.resourceType;
}
const response = await apiService.getAuditEvents(params);
setFilteredData(response.events);
} catch (error) {
console.error('Failed to apply filters:', error);
} finally {
setLoading(false);
}
if (filters.status) {
filtered = filtered.filter(entry => entry.status === filters.status);
}
if (filters.user) {
filtered = filtered.filter(entry =>
entry.user_id.toLowerCase().includes(filters.user.toLowerCase())
);
}
if (filters.resourceType) {
filtered = filtered.filter(entry => entry.resource_type === filters.resourceType);
}
setFilteredData(filtered);
};
const clearFilters = () => {
@ -180,7 +122,7 @@ const Audit: React.FC = () => {
user: '',
resourceType: '',
});
setFilteredData(auditData);
loadAuditData(); // Reload original data
};
const getStatusIcon = (status: string) => {
@ -216,42 +158,42 @@ const Audit: React.FC = () => {
</Text>
</div>
),
sorter: (a: AuditLogEntry, b: AuditLogEntry) =>
sorter: (a: AuditEvent, b: AuditEvent) =>
dayjs(a.timestamp).unix() - dayjs(b.timestamp).unix(),
defaultSortOrder: 'descend' as const,
},
{
title: 'User',
dataIndex: 'user_id',
key: 'user_id',
render: (userId: string) => (
dataIndex: 'actor_id',
key: 'actor_id',
render: (actorId: string) => (
<div>
<UserOutlined style={{ marginRight: '8px' }} />
{userId}
{actorId || 'System'}
</div>
),
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
render: (action: string) => (
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<div>
{getActionIcon(action)}
<span style={{ marginLeft: '8px' }}>{action.replace(/_/g, ' ')}</span>
{getActionIcon(type)}
<span style={{ marginLeft: '8px' }}>{type.replace(/_/g, ' ').replace(/\./g, ' ')}</span>
</div>
),
},
{
title: 'Resource',
key: 'resource',
render: (_: any, record: AuditLogEntry) => (
render: (_: any, record: AuditEvent) => (
<div>
<div>
<Tag color="blue">{record.resource_type.toUpperCase()}</Tag>
<Tag color="blue">{record.resource_type?.toUpperCase() || 'N/A'}</Tag>
</div>
<Text type="secondary" style={{ fontSize: '12px' }}>
{record.resource_id}
{record.resource_id || 'N/A'}
</Text>
</div>
),
@ -271,13 +213,13 @@ const Audit: React.FC = () => {
},
{
title: 'IP Address',
dataIndex: 'ip_address',
key: 'ip_address',
render: (ip: string) => <Text code>{ip}</Text>,
dataIndex: 'actor_ip',
key: 'actor_ip',
render: (ip: string) => <Text code>{ip || 'N/A'}</Text>,
},
];
const expandedRowRender = (record: AuditLogEntry) => (
const expandedRowRender = (record: AuditEvent) => (
<Card size="small" title="Event Details">
<Row gutter={16}>
<Col span={12}>
@ -360,7 +302,7 @@ const Audit: React.FC = () => {
<div style={{ textAlign: 'center' }}>
<UserOutlined style={{ fontSize: '32px', color: '#722ed1', marginBottom: '8px' }} />
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
{new Set(filteredData.map(e => e.user_id)).size}
{new Set(filteredData.map(e => e.actor_id).filter(id => id)).size}
</div>
<div>Unique Users</div>
</div>
@ -401,12 +343,14 @@ const Audit: React.FC = () => {
onChange={(value) => setFilters({ ...filters, action: value })}
allowClear
>
<Option value="CREATE_APPLICATION">Create Application</Option>
<Option value="UPDATE_APPLICATION">Update Application</Option>
<Option value="DELETE_APPLICATION">Delete Application</Option>
<Option value="CREATE_TOKEN">Create Token</Option>
<Option value="DELETE_TOKEN">Delete Token</Option>
<Option value="VERIFY_TOKEN">Verify Token</Option>
<Option value="app.created">Application Created</Option>
<Option value="app.updated">Application Updated</Option>
<Option value="app.deleted">Application Deleted</Option>
<Option value="auth.token_created">Token Created</Option>
<Option value="auth.token_revoked">Token Revoked</Option>
<Option value="auth.token_validated">Token Validated</Option>
<Option value="auth.login">Login</Option>
<Option value="auth.login_failed">Login Failed</Option>
</Select>
</Col>
<Col span={4}>
@ -465,16 +409,16 @@ const Audit: React.FC = () => {
color={entry.status === 'success' ? 'green' : entry.status === 'failure' ? 'red' : 'orange'}
>
<div>
<Text strong>{entry.action.replace(/_/g, ' ')}</Text>
<Text strong>{entry.type.replace(/_/g, ' ').replace(/\./g, ' ')}</Text>
<div>
<Text type="secondary">
{entry.user_id} {dayjs(entry.timestamp).fromNow()}
{entry.actor_id || 'System'} {dayjs(entry.timestamp).fromNow()}
</Text>
</div>
<div>
<Tag>{entry.resource_type}</Tag>
<Tag>{entry.resource_type || 'N/A'}</Tag>
<Text type="secondary" style={{ marginLeft: '8px' }}>
{entry.resource_id}
{entry.resource_id || 'N/A'}
</Text>
</div>
</div>
@ -485,13 +429,15 @@ const Audit: React.FC = () => {
{/* Audit Log Table */}
<Card title="Audit Log Entries">
<Alert
message="Demo Data"
description="This audit log shows simulated data for demonstration purposes. In production, this would display real audit events from your KMS system."
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
{filteredData.length === 0 && !loading && (
<Alert
message="No Audit Events Found"
description="No audit events match your current filters. Try adjusting the filters or check if any events have been logged to the system."
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
)}
<Table
columns={columns}

View File

@ -90,6 +90,59 @@ export interface VerifyResponse {
error?: string;
}
export interface AuditEvent {
id: string;
type: string;
status: string;
timestamp: string;
actor_id?: string;
actor_ip?: string;
user_agent?: string;
resource_id?: string;
resource_type?: string;
action: string;
description: string;
details?: Record<string, any>;
request_id?: string;
session_id?: string;
}
export interface AuditQueryParams {
event_types?: string[];
statuses?: string[];
actor_id?: string;
resource_id?: string;
resource_type?: string;
start_time?: string;
end_time?: string;
limit?: number;
offset?: number;
order_by?: string;
order_desc?: boolean;
}
export interface AuditResponse {
events: AuditEvent[];
total: number;
limit: number;
offset: number;
}
export interface AuditStats {
total_events: number;
by_type: Record<string, number>;
by_severity: Record<string, number>;
by_status: Record<string, number>;
by_time?: Record<string, number>;
}
export interface AuditStatsParams {
event_types?: string[];
start_time?: string;
end_time?: string;
group_by?: string;
}
class ApiService {
private api: AxiosInstance;
private baseURL: string;
@ -204,6 +257,55 @@ class ApiService {
});
return response.data;
}
// Audit endpoints
async getAuditEvents(params?: AuditQueryParams): Promise<AuditResponse> {
const queryString = new URLSearchParams();
if (params) {
if (params.event_types?.length) {
params.event_types.forEach(type => queryString.append('event_types', type));
}
if (params.statuses?.length) {
params.statuses.forEach(status => queryString.append('statuses', status));
}
if (params.actor_id) queryString.set('actor_id', params.actor_id);
if (params.resource_id) queryString.set('resource_id', params.resource_id);
if (params.resource_type) queryString.set('resource_type', params.resource_type);
if (params.start_time) queryString.set('start_time', params.start_time);
if (params.end_time) queryString.set('end_time', params.end_time);
if (params.limit) queryString.set('limit', params.limit.toString());
if (params.offset) queryString.set('offset', params.offset.toString());
if (params.order_by) queryString.set('order_by', params.order_by);
if (params.order_desc !== undefined) queryString.set('order_desc', params.order_desc.toString());
}
const url = `/api/audit/events${queryString.toString() ? '?' + queryString.toString() : ''}`;
const response = await this.api.get(url);
return response.data;
}
async getAuditEvent(eventId: string): Promise<AuditEvent> {
const response = await this.api.get(`/api/audit/events/${eventId}`);
return response.data;
}
async getAuditStats(params?: AuditStatsParams): Promise<AuditStats> {
const queryString = new URLSearchParams();
if (params) {
if (params.event_types?.length) {
params.event_types.forEach(type => queryString.append('event_types', type));
}
if (params.start_time) queryString.set('start_time', params.start_time);
if (params.end_time) queryString.set('end_time', params.end_time);
if (params.group_by) queryString.set('group_by', params.group_by);
}
const url = `/api/audit/stats${queryString.toString() ? '?' + queryString.toString() : ''}`;
const response = await this.api.get(url);
return response.data;
}
}
export const apiService = new ApiService();

View File

@ -0,0 +1,27 @@
-- Migration: 004_add_audit_events (down)
-- Remove audit_events table and related objects
-- Drop the cleanup function
DROP FUNCTION IF EXISTS cleanup_old_audit_events(INTEGER);
-- Drop indexes first (they will be dropped automatically with the table, but explicit for clarity)
DROP INDEX IF EXISTS idx_audit_events_timestamp;
DROP INDEX IF EXISTS idx_audit_events_type;
DROP INDEX IF EXISTS idx_audit_events_severity;
DROP INDEX IF EXISTS idx_audit_events_status;
DROP INDEX IF EXISTS idx_audit_events_actor_id;
DROP INDEX IF EXISTS idx_audit_events_actor_type;
DROP INDEX IF EXISTS idx_audit_events_tenant_id;
DROP INDEX IF EXISTS idx_audit_events_resource;
DROP INDEX IF EXISTS idx_audit_events_request_id;
DROP INDEX IF EXISTS idx_audit_events_session_id;
DROP INDEX IF EXISTS idx_audit_events_details;
DROP INDEX IF EXISTS idx_audit_events_metadata;
DROP INDEX IF EXISTS idx_audit_events_tags;
DROP INDEX IF EXISTS idx_audit_events_actor_timestamp;
DROP INDEX IF EXISTS idx_audit_events_type_timestamp;
DROP INDEX IF EXISTS idx_audit_events_tenant_timestamp;
DROP INDEX IF EXISTS idx_audit_events_resource_timestamp;
-- Drop the audit_events table
DROP TABLE IF EXISTS audit_events;

View File

@ -0,0 +1,102 @@
-- Migration: 004_add_audit_events
-- Add audit_events table for comprehensive audit logging
-- Create audit_events table
CREATE TABLE IF NOT EXISTS audit_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL CHECK (severity IN ('info', 'warning', 'error', 'critical')),
status VARCHAR(20) NOT NULL CHECK (status IN ('success', 'failure', 'pending')),
timestamp TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Actor information
actor_id VARCHAR(255),
actor_type VARCHAR(50) CHECK (actor_type IN ('user', 'system', 'service')),
actor_ip INET,
user_agent TEXT,
-- Tenant information (for multi-tenancy support)
tenant_id UUID,
-- Resource information
resource_id VARCHAR(255),
resource_type VARCHAR(100),
-- Event details
action VARCHAR(100) NOT NULL,
description TEXT NOT NULL,
details JSONB DEFAULT '{}',
-- Request context
request_id VARCHAR(100),
session_id VARCHAR(255),
-- Additional metadata
tags TEXT[],
metadata JSONB DEFAULT '{}'
);
-- Create indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_events_type ON audit_events(type);
CREATE INDEX IF NOT EXISTS idx_audit_events_severity ON audit_events(severity);
CREATE INDEX IF NOT EXISTS idx_audit_events_status ON audit_events(status);
CREATE INDEX IF NOT EXISTS idx_audit_events_actor_id ON audit_events(actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_events_actor_type ON audit_events(actor_type);
CREATE INDEX IF NOT EXISTS idx_audit_events_tenant_id ON audit_events(tenant_id) WHERE tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_events_resource ON audit_events(resource_type, resource_id) WHERE resource_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_events_request_id ON audit_events(request_id) WHERE request_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_events_session_id ON audit_events(session_id) WHERE session_id IS NOT NULL;
-- GIN indexes for JSONB columns
CREATE INDEX IF NOT EXISTS idx_audit_events_details ON audit_events USING GIN (details);
CREATE INDEX IF NOT EXISTS idx_audit_events_metadata ON audit_events USING GIN (metadata);
-- GIN index for tags array
CREATE INDEX IF NOT EXISTS idx_audit_events_tags ON audit_events USING GIN (tags);
-- Composite indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_audit_events_actor_timestamp ON audit_events(actor_id, timestamp DESC) WHERE actor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_events_type_timestamp ON audit_events(type, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_audit_events_tenant_timestamp ON audit_events(tenant_id, timestamp DESC) WHERE tenant_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_events_resource_timestamp ON audit_events(resource_type, resource_id, timestamp DESC) WHERE resource_id IS NOT NULL;
-- Add comments for documentation
COMMENT ON TABLE audit_events IS 'Comprehensive audit log for all system events and user actions';
COMMENT ON COLUMN audit_events.id IS 'Unique event identifier';
COMMENT ON COLUMN audit_events.type IS 'Event type (e.g., auth.login, app.created)';
COMMENT ON COLUMN audit_events.severity IS 'Event severity level: info, warning, error, critical';
COMMENT ON COLUMN audit_events.status IS 'Event status: success, failure, pending';
COMMENT ON COLUMN audit_events.timestamp IS 'When the event occurred';
COMMENT ON COLUMN audit_events.actor_id IS 'ID of the user/system that triggered the event';
COMMENT ON COLUMN audit_events.actor_type IS 'Type of actor: user, system, service';
COMMENT ON COLUMN audit_events.actor_ip IS 'IP address of the actor';
COMMENT ON COLUMN audit_events.user_agent IS 'User agent string (for HTTP requests)';
COMMENT ON COLUMN audit_events.tenant_id IS 'Tenant ID for multi-tenant environments';
COMMENT ON COLUMN audit_events.resource_id IS 'ID of the resource being acted upon';
COMMENT ON COLUMN audit_events.resource_type IS 'Type of resource (e.g., application, token)';
COMMENT ON COLUMN audit_events.action IS 'Action performed';
COMMENT ON COLUMN audit_events.description IS 'Human-readable description of the event';
COMMENT ON COLUMN audit_events.details IS 'Additional structured details as JSON';
COMMENT ON COLUMN audit_events.request_id IS 'Request ID for tracing';
COMMENT ON COLUMN audit_events.session_id IS 'Session ID for user session tracking';
COMMENT ON COLUMN audit_events.tags IS 'Array of tags for categorization';
COMMENT ON COLUMN audit_events.metadata IS 'Additional metadata as JSON';
-- Create a function to automatically clean up old audit events (optional)
CREATE OR REPLACE FUNCTION cleanup_old_audit_events(retention_days INTEGER DEFAULT 365)
RETURNS INTEGER AS $$
DECLARE
deleted_count INTEGER;
BEGIN
-- Delete audit events older than retention period
DELETE FROM audit_events
WHERE timestamp < NOW() - (retention_days || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION cleanup_old_audit_events(INTEGER) IS 'Function to clean up audit events older than specified days (default: 365 days)';

View File

@ -1,7 +1,7 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {

234
templates/login.html Normal file
View File

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - API Key Management Service</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
background: white;
border-radius: 10px;
box-shadow: 0 15px 35px rgba(0,0,0,0.1);
padding: 40px;
max-width: 500px;
width: 100%;
text-align: center;
}
h1 {
color: #333;
margin-bottom: 30px;
font-size: 28px;
font-weight: 300;
}
.token-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.token-display {
background: #2d3748;
color: #e2e8f0;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 14px;
word-break: break-all;
margin: 15px 0;
border: none;
width: 100%;
min-height: 60px;
resize: none;
}
.copy-button {
background: #4299e1;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
transition: background-color 0.2s;
}
.copy-button:hover {
background: #3182ce;
}
.status {
margin: 20px 0;
padding: 15px;
border-radius: 6px;
font-weight: 500;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #4299e1;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 15px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.hidden {
display: none;
}
.redirect-info {
font-size: 14px;
color: #666;
margin-top: 15px;
}
noscript .no-js-message {
background: #fff3cd;
color: #856404;
padding: 15px;
border: 1px solid #ffeaa7;
border-radius: 6px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>🔐 Login Success</h1>
<noscript>
<div class="no-js-message">
<strong>JavaScript is disabled.</strong> Please copy the token below and paste it into your application.
</div>
</noscript>
<div id="loading" class="status info">
<div class="spinner"></div>
<div>Processing login and redirecting...</div>
</div>
<div id="success-status" class="status success hidden">
✅ Login successful! Redirecting to your application...
</div>
<div id="error-status" class="status error hidden">
<div id="error-message">❌ Redirect failed. Please copy the token below manually.</div>
</div>
<div class="token-section">
<h3>Your Authentication Token</h3>
<p>Use this token to authenticate with the API:</p>
<textarea id="token-display" class="token-display" readonly>{{.Token}}</textarea>
<button id="copy-button" class="copy-button" onclick="copyToken()">📋 Copy Token</button>
<div class="redirect-info">
<strong>Token expires:</strong> {{.ExpiresAt}}<br>
<strong>Application:</strong> {{.AppID}}<br>
<strong>User:</strong> {{.UserID}}
</div>
</div>
</div>
<script>
// Token and redirect information from server
const token = {{.TokenJSON}};
const redirectURL = {{.RedirectURLJSON}};
// Elements
const loadingDiv = document.getElementById('loading');
const successDiv = document.getElementById('success-status');
const errorDiv = document.getElementById('error-status');
const errorMessage = document.getElementById('error-message');
function copyToken() {
const tokenDisplay = document.getElementById('token-display');
tokenDisplay.select();
tokenDisplay.setSelectionRange(0, 99999); // For mobile devices
try {
document.execCommand('copy');
const copyButton = document.getElementById('copy-button');
const originalText = copyButton.textContent;
copyButton.textContent = '✅ Copied!';
copyButton.style.background = '#48bb78';
setTimeout(() => {
copyButton.textContent = originalText;
copyButton.style.background = '#4299e1';
}, 2000);
} catch (err) {
console.error('Failed to copy token:', err);
}
}
function performRedirect() {
if (!redirectURL) {
// No redirect URL provided, just show success message
loadingDiv.classList.add('hidden');
successDiv.innerHTML = '✅ Login successful! You can now close this window and use the token above.';
successDiv.classList.remove('hidden');
return;
}
try {
// Show success status
loadingDiv.classList.add('hidden');
successDiv.classList.remove('hidden');
// Perform the redirect after a short delay
setTimeout(() => {
window.location.href = redirectURL;
}, 1500);
// Set a backup timer in case redirect fails
setTimeout(() => {
if (window.location.href.indexOf(redirectURL) === -1) {
// Redirect failed, show error
successDiv.classList.add('hidden');
errorDiv.classList.remove('hidden');
errorMessage.textContent = '❌ Automatic redirect failed. Please copy the token above and paste it into your application.';
}
}, 5000);
} catch (error) {
console.error('Redirect failed:', error);
loadingDiv.classList.add('hidden');
errorDiv.classList.remove('hidden');
errorMessage.textContent = '❌ Redirect failed: ' + error.message + '. Please copy the token above manually.';
}
}
// Start the redirect process when page loads
document.addEventListener('DOMContentLoaded', function() {
// Small delay to let the user see the page loaded
setTimeout(performRedirect, 1000);
});
// Token is always delivered via query parameter
</script>
</body>
</html>

View File

@ -189,6 +189,7 @@ test_application_endpoints() {
"app_link": "https://example.com/test-app",
"type": ["static"],
"callback_url": "https://example.com/callback",
"token_prefix": "TEST",
"token_renewal_duration": 604800000000000,
"max_token_duration": 2592000000000000,
"owner": {

View File

@ -96,8 +96,11 @@ func (suite *IntegrationTestSuite) setupServer() {
// Create a no-op logger for tests
logger := zap.NewNop()
// Initialize repositories
auditRepo := NewMockAuditRepository()
// Initialize services
appService := services.NewApplicationService(appRepo, logger)
appService := services.NewApplicationService(appRepo, auditRepo, logger)
tokenService := services.NewTokenService(tokenRepo, appRepo, permRepo, grantRepo, suite.cfg.GetString("INTERNAL_HMAC_KEY"), suite.cfg, logger)
authService := services.NewAuthenticationService(suite.cfg, logger, permRepo)

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/kms/api-key-service/internal/audit"
"github.com/kms/api-key-service/internal/domain"
"github.com/kms/api-key-service/internal/repository"
)
@ -612,3 +613,204 @@ func (m *MockGrantedPermissionRepository) HasAnyPermission(ctx context.Context,
return result, nil
}
// MockAuditRepository implements AuditRepository for testing
type MockAuditRepository struct {
mu sync.RWMutex
events []*audit.AuditEvent
}
func NewMockAuditRepository() repository.AuditRepository {
return &MockAuditRepository{
events: make([]*audit.AuditEvent, 0),
}
}
func (m *MockAuditRepository) Create(ctx context.Context, event *audit.AuditEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
if event.ID == uuid.Nil {
event.ID = uuid.New()
}
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
m.events = append(m.events, event)
return nil
}
func (m *MockAuditRepository) Query(ctx context.Context, filter *audit.AuditFilter) ([]*audit.AuditEvent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*audit.AuditEvent
for _, event := range m.events {
// Simple filtering logic for testing
if len(filter.EventTypes) > 0 {
found := false
for _, t := range filter.EventTypes {
if event.Type == t {
found = true
break
}
}
if !found {
continue
}
}
if filter.ActorID != "" && event.ActorID != filter.ActorID {
continue
}
if filter.ResourceID != "" && event.ResourceID != filter.ResourceID {
continue
}
if filter.ResourceType != "" && event.ResourceType != filter.ResourceType {
continue
}
result = append(result, event)
}
// Apply pagination
if filter.Offset >= len(result) {
return []*audit.AuditEvent{}, nil
}
end := filter.Offset + filter.Limit
if end > len(result) {
end = len(result)
}
return result[filter.Offset:end], nil
}
func (m *MockAuditRepository) GetStats(ctx context.Context, filter *audit.AuditStatsFilter) (*audit.AuditStats, error) {
m.mu.RLock()
defer m.mu.RUnlock()
stats := &audit.AuditStats{
TotalEvents: len(m.events),
ByType: make(map[audit.EventType]int),
BySeverity: make(map[audit.EventSeverity]int),
ByStatus: make(map[audit.EventStatus]int),
}
for _, event := range m.events {
stats.ByType[event.Type]++
stats.BySeverity[event.Severity]++
stats.ByStatus[event.Status]++
}
return stats, nil
}
func (m *MockAuditRepository) DeleteOldEvents(ctx context.Context, olderThan time.Time) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
var kept []*audit.AuditEvent
deleted := 0
for _, event := range m.events {
if event.Timestamp.Before(olderThan) {
deleted++
} else {
kept = append(kept, event)
}
}
m.events = kept
return deleted, nil
}
func (m *MockAuditRepository) GetByID(ctx context.Context, eventID uuid.UUID) (*audit.AuditEvent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, event := range m.events {
if event.ID == eventID {
return event, nil
}
}
return nil, fmt.Errorf("audit event with ID '%s' not found", eventID)
}
func (m *MockAuditRepository) GetByRequestID(ctx context.Context, requestID string) ([]*audit.AuditEvent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*audit.AuditEvent
for _, event := range m.events {
if event.RequestID == requestID {
result = append(result, event)
}
}
return result, nil
}
func (m *MockAuditRepository) GetBySession(ctx context.Context, sessionID string) ([]*audit.AuditEvent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*audit.AuditEvent
for _, event := range m.events {
if event.SessionID == sessionID {
result = append(result, event)
}
}
return result, nil
}
func (m *MockAuditRepository) GetByActor(ctx context.Context, actorID string, limit, offset int) ([]*audit.AuditEvent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var matching []*audit.AuditEvent
for _, event := range m.events {
if event.ActorID == actorID {
matching = append(matching, event)
}
}
if offset >= len(matching) {
return []*audit.AuditEvent{}, nil
}
end := offset + limit
if end > len(matching) {
end = len(matching)
}
return matching[offset:end], nil
}
func (m *MockAuditRepository) GetByResource(ctx context.Context, resourceType, resourceID string, limit, offset int) ([]*audit.AuditEvent, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var matching []*audit.AuditEvent
for _, event := range m.events {
if event.ResourceType == resourceType && event.ResourceID == resourceID {
matching = append(matching, event)
}
}
if offset >= len(matching) {
return []*audit.AuditEvent{}, nil
}
end := offset + limit
if end > len(matching) {
end = len(matching)
}
return matching[offset:end], nil
}