diff --git a/auth/auth.go b/auth/auth.go index 7db98b0..70405aa 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -198,6 +198,40 @@ func (s *AuthService) CheckResourcePermission(authCtx *AuthContext, resourceKey return CheckPermission(metadata.Permissions, operation, isOwner, isGroupMember) } +// GetResourceMetadata retrieves metadata for a resource +func (s *AuthService) GetResourceMetadata(resourceKey string) (*types.ResourceMetadata, error) { + var metadata types.ResourceMetadata + + err := s.db.View(func(txn *badger.Txn) error { + item, err := txn.Get([]byte(ResourceMetadataKey(resourceKey))) + if err != nil { + return err + } + + return item.Value(func(val []byte) error { + return json.Unmarshal(val, &metadata) + }) + }) + + if err != nil { + return nil, err + } + + return &metadata, nil +} + +// SetResourceMetadata stores metadata for a resource +func (s *AuthService) SetResourceMetadata(resourceKey string, metadata *types.ResourceMetadata) error { + metadataBytes, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %v", err) + } + + return s.db.Update(func(txn *badger.Txn) error { + return txn.Set([]byte(ResourceMetadataKey(resourceKey)), metadataBytes) + }) +} + // GetAuthContext retrieves auth context from request context func GetAuthContext(ctx context.Context) *AuthContext { if authCtx, ok := ctx.Value("auth").(*AuthContext); ok { diff --git a/integration_test.sh b/integration_test.sh index af6d3b6..4bb3796 100755 --- a/integration_test.sh +++ b/integration_test.sh @@ -444,6 +444,95 @@ EOF fi } +# Test 6: Resource Metadata Management (Issue #12) +test_metadata_management() { + test_start "Resource Metadata Management test (Issue #12)" + + # Create metadata test config + cat > metadata_test.yaml <metadata_test.log 2>&1 & + local pid=$! + + if wait_for_service 8096; then + sleep 2 # Allow root account creation + + # Extract the token from logs + local token=$(grep "Token:" metadata_test.log | sed 's/.*Token: //' | tr -d '\n\r') + + if [ -z "$token" ]; then + log_error "Failed to extract authentication token from logs" + kill $pid 2>/dev/null || true + return + fi + + # First, create a KV resource + curl -s -X PUT http://localhost:8096/kv/test/resource -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"data":"test"}' >/dev/null + sleep 1 + + # Test 1: Get metadata should fail for non-existent metadata (initially no metadata exists) + local get_response=$(curl -s -w "\n%{http_code}" -X GET http://localhost:8096/kv/test/resource/metadata -H "Authorization: Bearer $token") + local get_body=$(echo "$get_response" | head -n -1) + local get_code=$(echo "$get_response" | tail -n 1) + + if [ "$get_code" = "404" ]; then + log_success "GET metadata returns 404 for non-existent metadata" + else + log_error "GET metadata should return 404 for non-existent metadata, got code: $get_code, body: $get_body" + fi + + # Test 2: Update metadata should create new metadata + local update_response=$(curl -s -X PUT http://localhost:8096/kv/test/resource/metadata -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"owner_uuid":"test-owner-123","permissions":3840}') + if echo "$update_response" | grep -q "owner_uuid"; then + log_success "PUT metadata creates metadata successfully" + else + log_error "PUT metadata should create metadata, got: $update_response" + fi + + # Test 3: Get metadata should now return the created metadata + local get_response2=$(curl -s -X GET http://localhost:8096/kv/test/resource/metadata -H "Authorization: Bearer $token") + if echo "$get_response2" | grep -q "test-owner-123" && echo "$get_response2" | grep -q "3840"; then + log_success "GET metadata returns created metadata" + else + log_error "GET metadata should return created metadata, got: $get_response2" + fi + + # Test 4: Update metadata should modify existing metadata + local update_response2=$(curl -s -X PUT http://localhost:8096/kv/test/resource/metadata -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d '{"owner_uuid":"new-owner-456"}') + if echo "$update_response2" | grep -q "new-owner-456"; then + log_success "PUT metadata updates existing metadata" + else + log_error "PUT metadata should update metadata, got: $update_response2" + fi + + # Test 5: Metadata endpoints should require authentication + local no_auth=$(curl -s -w "\n%{http_code}" -X GET http://localhost:8096/kv/test/resource/metadata) + local no_auth_code=$(echo "$no_auth" | tail -n 1) + if [ "$no_auth_code" = "401" ]; then + log_success "Metadata endpoints properly require authentication" + else + log_error "Metadata endpoints should require authentication, got code: $no_auth_code" + fi + + kill $pid 2>/dev/null || true + sleep 2 + else + log_error "Metadata test node failed to start" + kill $pid 2>/dev/null || true + fi +} + # Main test execution main() { echo "==================================================" @@ -462,7 +551,8 @@ main() { test_cluster_formation test_conflict_resolution test_authentication_middleware - + test_metadata_management + # Results echo "==================================================" echo " Test Results" diff --git a/server/handlers.go b/server/handlers.go index 25184d9..1f76c39 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -213,6 +213,104 @@ func (s *Server) deleteKVHandler(w http.ResponseWriter, r *http.Request) { s.logger.WithField("path", path).Info("Value deleted") } +// getResourceMetadataHandler retrieves metadata for a KV resource +func (s *Server) getResourceMetadataHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + + // Get metadata from storage + metadata, err := s.authService.GetResourceMetadata(path) + if err == badger.ErrKeyNotFound { + http.Error(w, "Not Found: No metadata exists for this resource", http.StatusNotFound) + return + } + if err != nil { + s.logger.WithError(err).WithField("path", path).Error("Failed to get resource metadata") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + response := types.GetResourceMetadataResponse{ + OwnerUUID: metadata.OwnerUUID, + GroupUUID: metadata.GroupUUID, + Permissions: metadata.Permissions, + TTL: metadata.TTL, + CreatedAt: metadata.CreatedAt, + UpdatedAt: metadata.UpdatedAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// updateResourceMetadataHandler updates metadata for a KV resource +func (s *Server) updateResourceMetadataHandler(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + path := vars["path"] + + // Parse request body + var req types.UpdateResourceMetadataRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Bad Request: Invalid JSON", http.StatusBadRequest) + return + } + + // Get existing metadata or create new one + metadata, err := s.authService.GetResourceMetadata(path) + if err == badger.ErrKeyNotFound { + // Create new metadata with defaults + metadata = &types.ResourceMetadata{ + OwnerUUID: "", + GroupUUID: "", + Permissions: types.DefaultPermissions, + TTL: "", + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + } + } else if err != nil { + s.logger.WithError(err).WithField("path", path).Error("Failed to get resource metadata") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Update only provided fields + if req.OwnerUUID != nil { + metadata.OwnerUUID = *req.OwnerUUID + } + if req.GroupUUID != nil { + metadata.GroupUUID = *req.GroupUUID + } + if req.Permissions != nil { + metadata.Permissions = *req.Permissions + } + metadata.UpdatedAt = time.Now().Unix() + + // Store updated metadata + if err := s.authService.SetResourceMetadata(path, metadata); err != nil { + s.logger.WithError(err).WithField("path", path).Error("Failed to update resource metadata") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + response := types.GetResourceMetadataResponse{ + OwnerUUID: metadata.OwnerUUID, + GroupUUID: metadata.GroupUUID, + Permissions: metadata.Permissions, + TTL: metadata.TTL, + CreatedAt: metadata.CreatedAt, + UpdatedAt: metadata.UpdatedAt, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + + s.logger.WithFields(logrus.Fields{ + "path": path, + "owner_uuid": metadata.OwnerUUID, + "group_uuid": metadata.GroupUUID, + }).Info("Resource metadata updated") +} + // isClusterMember checks if request is from a cluster member func (s *Server) isClusterMember(remoteAddr string) bool { host, _, err := net.SplitHostPort(remoteAddr) diff --git a/server/routes.go b/server/routes.go index c05547f..a927d76 100644 --- a/server/routes.go +++ b/server/routes.go @@ -13,6 +13,18 @@ func (s *Server) setupRoutes() *mux.Router { // Health endpoint (always available) router.HandleFunc("/health", s.healthHandler).Methods("GET") + // Resource Metadata Management endpoints (Issue #12) - Must come BEFORE general KV routes + // These need to be registered first to prevent /kv/{path:.+} from matching metadata paths + if s.config.AuthEnabled { + router.Handle("/kv/{path:.+}/metadata", s.authService.Middleware( + []string{"admin:users:read"}, nil, "", + )(s.getResourceMetadataHandler)).Methods("GET") + + router.Handle("/kv/{path:.+}/metadata", s.authService.Middleware( + []string{"admin:users:update"}, nil, "", + )(s.updateResourceMetadataHandler)).Methods("PUT") + } + // KV endpoints (with conditional authentication based on anonymous access settings) // GET endpoint - require auth if anonymous read is disabled if s.config.AuthEnabled && !s.config.AllowAnonymousRead { diff --git a/types/types.go b/types/types.go index 1032669..0c90b2c 100644 --- a/types/types.go +++ b/types/types.go @@ -131,6 +131,22 @@ type CreateTokenResponse struct { ExpiresAt int64 `json:"expires_at"` } +// Resource Metadata Management API structures (Issue #12) +type GetResourceMetadataResponse struct { + OwnerUUID string `json:"owner_uuid"` + GroupUUID string `json:"group_uuid"` + Permissions int `json:"permissions"` + TTL string `json:"ttl"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type UpdateResourceMetadataRequest struct { + OwnerUUID *string `json:"owner_uuid,omitempty"` + GroupUUID *string `json:"group_uuid,omitempty"` + Permissions *int `json:"permissions,omitempty"` +} + // Cluster and member management types type Member struct { ID string `json:"id"`