feat: implement resource metadata management API (issue #12)
Add API endpoints to manage ResourceMetadata (ownership, groups, permissions) for KV resources. This enables administrators to configure granular access control for stored data. Changes: - Add GetResourceMetadataResponse and UpdateResourceMetadataRequest types - Add GetResourceMetadata and SetResourceMetadata methods to AuthService - Add GET /kv/{path}/metadata endpoint (requires admin:users:read) - Add PUT /kv/{path}/metadata endpoint (requires admin:users:update) - Both endpoints protected by JWT authentication - Metadata routes registered before general KV routes to prevent pattern conflicts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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)
|
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
|
// GetAuthContext retrieves auth context from request context
|
||||||
func GetAuthContext(ctx context.Context) *AuthContext {
|
func GetAuthContext(ctx context.Context) *AuthContext {
|
||||||
if authCtx, ok := ctx.Value("auth").(*AuthContext); ok {
|
if authCtx, ok := ctx.Value("auth").(*AuthContext); ok {
|
||||||
|
@@ -444,6 +444,95 @@ EOF
|
|||||||
fi
|
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 test execution
|
||||||
main() {
|
main() {
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
@@ -462,6 +551,7 @@ main() {
|
|||||||
test_cluster_formation
|
test_cluster_formation
|
||||||
test_conflict_resolution
|
test_conflict_resolution
|
||||||
test_authentication_middleware
|
test_authentication_middleware
|
||||||
|
test_metadata_management
|
||||||
|
|
||||||
# Results
|
# Results
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
@@ -213,6 +213,104 @@ func (s *Server) deleteKVHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.logger.WithField("path", path).Info("Value deleted")
|
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
|
// isClusterMember checks if request is from a cluster member
|
||||||
func (s *Server) isClusterMember(remoteAddr string) bool {
|
func (s *Server) isClusterMember(remoteAddr string) bool {
|
||||||
host, _, err := net.SplitHostPort(remoteAddr)
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
@@ -13,6 +13,18 @@ func (s *Server) setupRoutes() *mux.Router {
|
|||||||
// Health endpoint (always available)
|
// Health endpoint (always available)
|
||||||
router.HandleFunc("/health", s.healthHandler).Methods("GET")
|
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)
|
// KV endpoints (with conditional authentication based on anonymous access settings)
|
||||||
// GET endpoint - require auth if anonymous read is disabled
|
// GET endpoint - require auth if anonymous read is disabled
|
||||||
if s.config.AuthEnabled && !s.config.AllowAnonymousRead {
|
if s.config.AuthEnabled && !s.config.AllowAnonymousRead {
|
||||||
|
@@ -131,6 +131,22 @@ type CreateTokenResponse struct {
|
|||||||
ExpiresAt int64 `json:"expires_at"`
|
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
|
// Cluster and member management types
|
||||||
type Member struct {
|
type Member struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Reference in New Issue
Block a user