metadata-api #15
34
auth/auth.go
34
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 {
|
||||
|
@@ -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 <<EOF
|
||||
node_id: "metadata-test"
|
||||
bind_address: "127.0.0.1"
|
||||
port: 8096
|
||||
data_dir: "./metadata_test_data"
|
||||
seed_nodes: []
|
||||
log_level: "error"
|
||||
auth_enabled: true
|
||||
allow_anonymous_read: false
|
||||
allow_anonymous_write: false
|
||||
EOF
|
||||
|
||||
# Start node
|
||||
$BINARY 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"
|
||||
|
@@ -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)
|
||||
|
@@ -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 {
|
||||
|
@@ -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"`
|
||||
|
Reference in New Issue
Block a user