From e1c7e825af0dfc0d1e8317232fb8c6c9b9c343f4 Mon Sep 17 00:00:00 2001 From: Ryan Copley Date: Tue, 26 Aug 2025 13:06:43 -0400 Subject: [PATCH] - --- cmd/server/main.go | 49 ++++++- internal/audit/audit.go | 9 ++ internal/auth/permissions.go | 101 ++++++++++++--- internal/errors/secure_responses.go | 22 ++++ internal/handlers/audit.go | 38 +++++- internal/handlers/token.go | 157 ++++++++++++++++------- internal/services/application_service.go | 5 + 7 files changed, 304 insertions(+), 77 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 5e2a029..68c72fd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "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" @@ -285,9 +286,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 } diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 106ba39..9b3ac22 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -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) diff --git a/internal/auth/permissions.go b/internal/auth/permissions.go index 4f659e5..3369cca 100644 --- a/internal/auth/permissions.go +++ b/internal/auth/permissions.go @@ -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 +} diff --git a/internal/errors/secure_responses.go b/internal/errors/secure_responses.go index 7f17ff8..4518023 100644 --- a/internal/errors/secure_responses.go +++ b/internal/errors/secure_responses.go @@ -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 { diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index d68f0f4..ab8d299 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -192,16 +192,44 @@ func (h *AuditHandler) ListEvents(c *gin.Context) { // GetEvent handles GET /audit/events/:id func (h *AuditHandler) GetEvent(c *gin.Context) { eventIDStr := c.Param("id") - _, err := uuid.Parse(eventIDStr) + eventID, err := uuid.Parse(eventIDStr) if err != nil { h.errorHandler.HandleValidationError(c, "id", "Invalid event ID format") return } - // Single event retrieval not yet implemented - c.JSON(http.StatusNotImplemented, gin.H{ - "error": "Single event retrieval not yet implemented", - }) + // 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 diff --git a/internal/handlers/token.go b/internal/handlers/token.go index fe78d50..3dd0230 100644 --- a/internal/handlers/token.go +++ b/internal/handlers/token.go @@ -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) } diff --git a/internal/services/application_service.go b/internal/services/application_service.go index 0540aab..bad15b7 100644 --- a/internal/services/application_service.go +++ b/internal/services/application_service.go @@ -8,6 +8,7 @@ import ( "time" "github.com/go-playground/validator/v10" + "github.com/google/uuid" "go.uber.org/zap" "github.com/kms/api-key-service/internal/audit" @@ -60,6 +61,10 @@ func (a *auditRepositoryAdapter) DeleteOldEvents(ctx context.Context, olderThan 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))