A Kubernetes operator that bridges Hardware Security Module (HSM) data storage with Kubernetes Secrets, providing true secret portability th
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix secret metadata being added in webui and syncing to secrets

+772 -412
+19 -28
api/proto/hsm/v1/hsm.pb.go
··· 144 144 145 145 type SecretMetadata struct { 146 146 state protoimpl.MessageState `protogen:"open.v1"` 147 - Label string `protobuf:"bytes,1,opt,name=label,proto3" json:"label,omitempty"` 148 - Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` 149 - Tags map[string]string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` 150 - Format string `protobuf:"bytes,4,opt,name=format,proto3" json:"format,omitempty"` 151 - DataType string `protobuf:"bytes,5,opt,name=data_type,json=dataType,proto3" json:"data_type,omitempty"` 152 - CreatedAt string `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` 153 - Source string `protobuf:"bytes,7,opt,name=source,proto3" json:"source,omitempty"` 147 + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` 148 + Labels map[string]string `protobuf:"bytes,2,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` 149 + Format string `protobuf:"bytes,3,opt,name=format,proto3" json:"format,omitempty"` 150 + DataType string `protobuf:"bytes,4,opt,name=data_type,json=dataType,proto3" json:"data_type,omitempty"` 151 + CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` 152 + Source string `protobuf:"bytes,6,opt,name=source,proto3" json:"source,omitempty"` 154 153 unknownFields protoimpl.UnknownFields 155 154 sizeCache protoimpl.SizeCache 156 155 } ··· 185 184 return file_hsm_v1_hsm_proto_rawDescGZIP(), []int{2} 186 185 } 187 186 188 - func (x *SecretMetadata) GetLabel() string { 189 - if x != nil { 190 - return x.Label 191 - } 192 - return "" 193 - } 194 - 195 187 func (x *SecretMetadata) GetDescription() string { 196 188 if x != nil { 197 189 return x.Description ··· 199 191 return "" 200 192 } 201 193 202 - func (x *SecretMetadata) GetTags() map[string]string { 194 + func (x *SecretMetadata) GetLabels() map[string]string { 203 195 if x != nil { 204 - return x.Tags 196 + return x.Labels 205 197 } 206 198 return nil 207 199 } ··· 1115 1107 "\x04data\x18\x01 \x03(\v2\x1c.hsm.v1.SecretData.DataEntryR\x04data\x1a7\n" + 1116 1108 "\tDataEntry\x12\x10\n" + 1117 1109 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + 1118 - "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\xa3\x02\n" + 1119 - "\x0eSecretMetadata\x12\x14\n" + 1120 - "\x05label\x18\x01 \x01(\tR\x05label\x12 \n" + 1121 - "\vdescription\x18\x02 \x01(\tR\vdescription\x124\n" + 1122 - "\x04tags\x18\x03 \x03(\v2 .hsm.v1.SecretMetadata.TagsEntryR\x04tags\x12\x16\n" + 1123 - "\x06format\x18\x04 \x01(\tR\x06format\x12\x1b\n" + 1124 - "\tdata_type\x18\x05 \x01(\tR\bdataType\x12\x1d\n" + 1110 + "\x05value\x18\x02 \x01(\fR\x05value:\x028\x01\"\x95\x02\n" + 1111 + "\x0eSecretMetadata\x12 \n" + 1112 + "\vdescription\x18\x01 \x01(\tR\vdescription\x12:\n" + 1113 + "\x06labels\x18\x02 \x03(\v2\".hsm.v1.SecretMetadata.LabelsEntryR\x06labels\x12\x16\n" + 1114 + "\x06format\x18\x03 \x01(\tR\x06format\x12\x1b\n" + 1115 + "\tdata_type\x18\x04 \x01(\tR\bdataType\x12\x1d\n" + 1125 1116 "\n" + 1126 - "created_at\x18\x06 \x01(\tR\tcreatedAt\x12\x16\n" + 1127 - "\x06source\x18\a \x01(\tR\x06source\x1a7\n" + 1128 - "\tTagsEntry\x12\x10\n" + 1117 + "created_at\x18\x05 \x01(\tR\tcreatedAt\x12\x16\n" + 1118 + "\x06source\x18\x06 \x01(\tR\x06source\x1a9\n" + 1119 + "\vLabelsEntry\x12\x10\n" + 1129 1120 "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + 1130 1121 "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x10\n" + 1131 1122 "\x0eGetInfoRequest\"=\n" + ··· 1220 1211 (*HealthRequest)(nil), // 21: hsm.v1.HealthRequest 1221 1212 (*HealthResponse)(nil), // 22: hsm.v1.HealthResponse 1222 1213 nil, // 23: hsm.v1.SecretData.DataEntry 1223 - nil, // 24: hsm.v1.SecretMetadata.TagsEntry 1214 + nil, // 24: hsm.v1.SecretMetadata.LabelsEntry 1224 1215 } 1225 1216 var file_hsm_v1_hsm_proto_depIdxs = []int32{ 1226 1217 23, // 0: hsm.v1.SecretData.data:type_name -> hsm.v1.SecretData.DataEntry 1227 - 24, // 1: hsm.v1.SecretMetadata.tags:type_name -> hsm.v1.SecretMetadata.TagsEntry 1218 + 24, // 1: hsm.v1.SecretMetadata.labels:type_name -> hsm.v1.SecretMetadata.LabelsEntry 1228 1219 0, // 2: hsm.v1.GetInfoResponse.hsm_info:type_name -> hsm.v1.HSMInfo 1229 1220 1, // 3: hsm.v1.ReadSecretResponse.secret_data:type_name -> hsm.v1.SecretData 1230 1221 1, // 4: hsm.v1.WriteSecretRequest.secret_data:type_name -> hsm.v1.SecretData
+6 -7
api/proto/hsm/v1/hsm.proto
··· 51 51 } 52 52 53 53 message SecretMetadata { 54 - string label = 1; 55 - string description = 2; 56 - map<string, string> tags = 3; 57 - string format = 4; 58 - string data_type = 5; 59 - string created_at = 6; 60 - string source = 7; 54 + string description = 1; 55 + map<string, string> labels = 2; 56 + string format = 3; 57 + string data_type = 4; 58 + string created_at = 5; 59 + string source = 6; 61 60 } 62 61 63 62 // Request/Response messages
+2 -2
helm/hsm-secrets-operator/Chart.yaml
··· 2 2 name: hsm-secrets-operator 3 3 description: A Kubernetes operator that bridges Pico HSM binary data storage with Kubernetes Secrets 4 4 type: application 5 - version: 0.5.8 6 - appVersion: v0.5.8 5 + version: 0.5.9 6 + appVersion: v0.5.9 7 7 icon: https://raw.githubusercontent.com/cncf/artwork/master/projects/kubernetes/icon/color/kubernetes-icon-color.svg 8 8 home: https://github.com/evanjarrett/hsm-secrets-operator 9 9 sources:
+4 -6
internal/agent/grpc_client.go
··· 183 183 // Convert metadata if provided 184 184 if metadata != nil { 185 185 req.Metadata = &hsmv1.SecretMetadata{ 186 - Label: metadata.Label, 187 186 Description: metadata.Description, 188 - Tags: metadata.Tags, 187 + Labels: metadata.Labels, 189 188 Format: metadata.Format, 190 - DataType: string(metadata.DataType), 189 + DataType: metadata.DataType, 191 190 CreatedAt: metadata.CreatedAt, 192 191 Source: metadata.Source, 193 192 } ··· 218 217 } 219 218 220 219 return &hsm.SecretMetadata{ 221 - Label: resp.Metadata.Label, 222 220 Description: resp.Metadata.Description, 223 - Tags: resp.Metadata.Tags, 221 + Labels: resp.Metadata.Labels, 224 222 Format: resp.Metadata.Format, 225 - DataType: hsm.SecretDataType(resp.Metadata.DataType), 223 + DataType: resp.Metadata.DataType, 226 224 CreatedAt: resp.Metadata.CreatedAt, 227 225 Source: resp.Metadata.Source, 228 226 }, nil
+6 -9
internal/agent/grpc_client_test.go
··· 322 322 "data": []byte("test-data"), 323 323 } 324 324 metadata := &hsm.SecretMetadata{ 325 - Label: "Test Secret", 326 325 Description: "A test secret", 327 - Tags: map[string]string{"category": "test", "env": "demo"}, 326 + Labels: map[string]string{"category": "test", "env": "demo"}, 328 327 Format: "raw", 329 - DataType: hsm.DataTypePlaintext, 328 + DataType: "plaintext", 330 329 CreatedAt: "2025-01-01T00:00:00Z", 331 330 Source: "test", 332 331 } ··· 339 338 // First write secret with metadata 340 339 secretData := hsm.SecretData{"data": []byte("test")} 341 340 metadata := &hsm.SecretMetadata{ 342 - Label: "Metadata Test", 343 341 Description: "Test metadata reading", 344 - Tags: map[string]string{"type": "metadata"}, 342 + Labels: map[string]string{"type": "metadata"}, 345 343 Format: "json", 346 - DataType: hsm.DataTypeJson, 344 + DataType: "json", 347 345 CreatedAt: "2025-01-01T12:00:00Z", 348 346 Source: "unit-test", 349 347 } ··· 354 352 readMetadata, err := client.ReadMetadata(ctx, "metadata-read-test") 355 353 require.NoError(t, err) 356 354 require.NotNil(t, readMetadata) 357 - assert.Equal(t, "Metadata Test", readMetadata.Label) 358 355 assert.Equal(t, "Test metadata reading", readMetadata.Description) 359 - assert.Equal(t, map[string]string{"type": "metadata"}, readMetadata.Tags) 356 + assert.Equal(t, map[string]string{"type": "metadata"}, readMetadata.Labels) 360 357 assert.Equal(t, "json", readMetadata.Format) 361 - assert.Equal(t, hsm.DataTypeJson, readMetadata.DataType) 358 + assert.Equal(t, "json", readMetadata.DataType) 362 359 }) 363 360 364 361 t.Run("DeleteSecret", func(t *testing.T) {
+4 -6
internal/agent/grpc_integration_test.go
··· 137 137 "certificate": []byte("-----BEGIN CERTIFICATE-----"), 138 138 } 139 139 metadata := &hsm.SecretMetadata{ 140 - Label: "SSL Certificate", 141 140 Description: "Production SSL certificate", 142 - Tags: map[string]string{"env": "prod", "type": "ssl"}, 141 + Labels: map[string]string{"env": "prod", "type": "ssl"}, 143 142 Format: "pem", 144 - DataType: hsm.DataTypePem, 143 + DataType: "pem", 145 144 CreatedAt: "2025-01-01T00:00:00Z", 146 145 Source: "integration-test", 147 146 } ··· 158 157 readMetadata, err := client.ReadMetadata(ctx, "ssl-cert") 159 158 require.NoError(t, err) 160 159 require.NotNil(t, readMetadata) 161 - assert.Equal(t, "SSL Certificate", readMetadata.Label) 162 160 assert.Equal(t, "Production SSL certificate", readMetadata.Description) 163 - assert.Equal(t, map[string]string{"env": "prod", "type": "ssl"}, readMetadata.Tags) 161 + assert.Equal(t, map[string]string{"env": "prod", "type": "ssl"}, readMetadata.Labels) 164 162 assert.Equal(t, "pem", readMetadata.Format) 165 - assert.Equal(t, hsm.DataTypePem, readMetadata.DataType) 163 + assert.Equal(t, "pem", readMetadata.DataType) 166 164 }) 167 165 168 166 t.Run("ListSecrets", func(t *testing.T) {
+4 -6
internal/agent/grpc_server.go
··· 217 217 var metadata *hsm.SecretMetadata 218 218 if req.Metadata != nil { 219 219 metadata = &hsm.SecretMetadata{ 220 - Label: req.Metadata.Label, 221 220 Description: req.Metadata.Description, 222 - Tags: req.Metadata.Tags, 221 + Labels: req.Metadata.Labels, 223 222 Format: req.Metadata.Format, 224 - DataType: hsm.SecretDataType(req.Metadata.DataType), 223 + DataType: req.Metadata.DataType, 225 224 CreatedAt: req.Metadata.CreatedAt, 226 225 Source: req.Metadata.Source, 227 226 } ··· 254 253 var pbMetadata *hsmv1.SecretMetadata 255 254 if metadata != nil { 256 255 pbMetadata = &hsmv1.SecretMetadata{ 257 - Label: metadata.Label, 258 256 Description: metadata.Description, 259 - Tags: metadata.Tags, 257 + Labels: metadata.Labels, 260 258 Format: metadata.Format, 261 - DataType: string(metadata.DataType), 259 + DataType: metadata.DataType, 262 260 CreatedAt: metadata.CreatedAt, 263 261 Source: metadata.Source, 264 262 }
+5 -9
internal/agent/grpc_server_test.go
··· 209 209 }, 210 210 }, 211 211 Metadata: &hsmv1.SecretMetadata{ 212 - Label: "SSL Certificate", 213 212 Description: "Production SSL certificate", 214 - Tags: map[string]string{"env": "prod", "type": "ssl"}, 213 + Labels: map[string]string{"env": "prod", "type": "ssl"}, 215 214 Format: "pem", 216 215 DataType: "pem", 217 216 CreatedAt: "2025-01-01T00:00:00Z", ··· 232 231 metadata, err := mockClient.ReadMetadata(ctx, "secret-with-metadata") 233 232 require.NoError(t, err) 234 233 require.NotNil(t, metadata) 235 - assert.Equal(t, "SSL Certificate", metadata.Label) 236 234 assert.Equal(t, "Production SSL certificate", metadata.Description) 237 - assert.Equal(t, map[string]string{"env": "prod", "type": "ssl"}, metadata.Tags) 235 + assert.Equal(t, map[string]string{"env": "prod", "type": "ssl"}, metadata.Labels) 238 236 assert.Equal(t, "pem", metadata.Format) 239 237 }) 240 238 ··· 291 289 // Write secret with metadata 292 290 testData := hsm.SecretData{"data": []byte("test")} 293 291 metadata := &hsm.SecretMetadata{ 294 - Label: "Test Metadata", 295 292 Description: "Test description", 296 - Tags: map[string]string{"type": "test"}, 293 + Labels: map[string]string{"type": "test"}, 297 294 Format: "json", 298 - DataType: hsm.DataTypeJson, 295 + DataType: "json", 299 296 CreatedAt: "2025-01-01T12:00:00Z", 300 297 Source: "unit-test", 301 298 } ··· 307 304 resp, err := server.ReadMetadata(ctx, req) 308 305 require.NoError(t, err) 309 306 require.NotNil(t, resp.Metadata) 310 - assert.Equal(t, "Test Metadata", resp.Metadata.Label) 311 307 assert.Equal(t, "Test description", resp.Metadata.Description) 312 - assert.Equal(t, map[string]string{"type": "test"}, resp.Metadata.Tags) 308 + assert.Equal(t, map[string]string{"type": "test"}, resp.Metadata.Labels) 313 309 assert.Equal(t, "json", resp.Metadata.Format) 314 310 assert.Equal(t, "json", resp.Metadata.DataType) 315 311 })
+6 -6
internal/api/proxy_client.go
··· 282 282 // Add mirroring metadata 283 283 metadata := req.Metadata 284 284 if metadata == nil { 285 - metadata = &hsm.SecretMetadata{Tags: make(map[string]string)} 285 + metadata = &hsm.SecretMetadata{Labels: make(map[string]string)} 286 286 } 287 - if metadata.Tags == nil { 288 - metadata.Tags = make(map[string]string) 287 + if metadata.Labels == nil { 288 + metadata.Labels = make(map[string]string) 289 289 } 290 - metadata.Tags["sync.version"] = fmt.Sprintf("%d", time.Now().Unix()) 291 - metadata.Tags["sync.timestamp"] = time.Now().Format(time.RFC3339) 292 - metadata.Tags["sync.mirrored"] = "true" 290 + metadata.Labels["sync.version"] = fmt.Sprintf("%d", time.Now().Unix()) 291 + metadata.Labels["sync.timestamp"] = time.Now().Format(time.RFC3339) 292 + metadata.Labels["sync.mirrored"] = "true" 293 293 294 294 // Write to all devices in parallel 295 295 results := p.writeToAllDevices(c.Request.Context(), clients, path, data, metadata)
+129 -10
internal/controller/hsmsecret_controller.go
··· 181 181 return ctrl.Result{RequeueAfter: time.Minute * 2}, err 182 182 } 183 183 184 + // Read metadata from HSM via agent 185 + hsmMetadata, err := hsmClient.ReadMetadata(ctx, hsmSecret.Name) 186 + if err != nil { 187 + logger.V(1).Info("Failed to read metadata from HSM (this is normal if no metadata exists)", "path", hsmSecret.Name, "error", err) 188 + hsmMetadata = nil 189 + } 190 + 184 191 // Calculate HSM checksum 185 192 hsmChecksum := hsm.CalculateChecksum(hsmData) 186 193 ··· 195 202 if err != nil { 196 203 if errors.IsNotFound(err) { 197 204 // Create new secret 198 - k8sSecret = r.buildSecret(hsmSecret, secretName, hsmData) 205 + k8sSecret = r.buildSecret(hsmSecret, secretName, hsmData, hsmMetadata) 199 206 if err := r.Create(ctx, &k8sSecret); err != nil { 200 207 logger.Error(err, "Failed to create Secret") 201 208 return ctrl.Result{}, err ··· 207 214 } 208 215 } else { 209 216 // Update existing secret if needed 210 - k8sSecret.Data = r.convertHSMDataToSecretData(hsmData) 217 + r.updateSecretWithMetadata(&k8sSecret, hsmSecret, hsmData, hsmMetadata) 211 218 if err := r.Update(ctx, &k8sSecret); err != nil { 212 219 logger.Error(err, "Failed to update Secret") 213 220 return ctrl.Result{}, err ··· 286 293 return ctrl.Result{}, nil 287 294 } 288 295 289 - // buildSecret creates a new Kubernetes Secret from HSM data 290 - func (r *HSMSecretReconciler) buildSecret(hsmSecret *hsmv1alpha1.HSMSecret, secretName string, hsmData hsm.SecretData) corev1.Secret { 296 + // buildSecret creates a new Kubernetes Secret from HSM data and metadata 297 + func (r *HSMSecretReconciler) buildSecret(hsmSecret *hsmv1alpha1.HSMSecret, secretName string, hsmData hsm.SecretData, hsmMetadata *hsm.SecretMetadata) corev1.Secret { 291 298 secretType := hsmSecret.Spec.SecretType 292 299 if secretType == "" { 293 300 secretType = corev1.SecretTypeOpaque 294 301 } 295 302 303 + // Build labels starting with default operator labels 304 + labels := map[string]string{ 305 + "managed-by": "hsm-secrets-operator", 306 + "hsm-path": strings.ReplaceAll(hsmSecret.Name, "/", "_"), 307 + } 308 + 309 + // Build annotations starting with empty map 310 + annotations := make(map[string]string) 311 + 312 + // Add metadata labels and annotations if metadata exists 313 + r.applyMetadataToLabelsAndAnnotations(labels, annotations, hsmMetadata) 314 + 296 315 secret := corev1.Secret{ 297 316 ObjectMeta: metav1.ObjectMeta{ 298 - Name: secretName, 299 - Namespace: hsmSecret.Namespace, 300 - Labels: map[string]string{ 301 - "managed-by": "hsm-secrets-operator", 302 - "hsm-path": strings.ReplaceAll(hsmSecret.Name, "/", "_"), 303 - }, 317 + Name: secretName, 318 + Namespace: hsmSecret.Namespace, 319 + Labels: labels, 320 + Annotations: annotations, 304 321 }, 305 322 Type: secretType, 306 323 Data: r.convertHSMDataToSecretData(hsmData), ··· 312 329 } 313 330 314 331 return secret 332 + } 333 + 334 + // updateSecretWithMetadata updates an existing Kubernetes Secret with HSM data and metadata 335 + func (r *HSMSecretReconciler) updateSecretWithMetadata(secret *corev1.Secret, hsmSecret *hsmv1alpha1.HSMSecret, hsmData hsm.SecretData, hsmMetadata *hsm.SecretMetadata) { 336 + // Update data 337 + secret.Data = r.convertHSMDataToSecretData(hsmData) 338 + 339 + // Initialize labels if nil 340 + if secret.Labels == nil { 341 + secret.Labels = make(map[string]string) 342 + } 343 + 344 + // Initialize annotations if nil 345 + if secret.Annotations == nil { 346 + secret.Annotations = make(map[string]string) 347 + } 348 + 349 + // Ensure essential operator labels are present 350 + secret.Labels["managed-by"] = "hsm-secrets-operator" 351 + secret.Labels["hsm-path"] = strings.ReplaceAll(hsmSecret.Name, "/", "_") 352 + 353 + // Apply metadata to labels and annotations 354 + r.applyMetadataToLabelsAndAnnotations(secret.Labels, secret.Annotations, hsmMetadata) 355 + } 356 + 357 + // applyMetadataToLabelsAndAnnotations applies HSM metadata to Kubernetes labels and annotations 358 + func (r *HSMSecretReconciler) applyMetadataToLabelsAndAnnotations(labels map[string]string, annotations map[string]string, hsmMetadata *hsm.SecretMetadata) { 359 + if hsmMetadata == nil { 360 + return 361 + } 362 + 363 + // Apply metadata labels directly to Kubernetes labels 364 + if hsmMetadata.Labels != nil { 365 + for key, value := range hsmMetadata.Labels { 366 + // Validate Kubernetes label format 367 + if r.isValidKubernetesLabelKey(key) && r.isValidKubernetesLabelValue(value) { 368 + labels[key] = value 369 + } 370 + } 371 + } 372 + 373 + // Apply other metadata fields as annotations with hsm.j5t.io prefix 374 + if hsmMetadata.Description != "" { 375 + annotations["hsm.j5t.io/description"] = hsmMetadata.Description 376 + } 377 + if hsmMetadata.Format != "" { 378 + annotations["hsm.j5t.io/format"] = hsmMetadata.Format 379 + } 380 + if hsmMetadata.DataType != "" { 381 + annotations["hsm.j5t.io/data-type"] = hsmMetadata.DataType 382 + } 383 + if hsmMetadata.Source != "" { 384 + annotations["hsm.j5t.io/source"] = hsmMetadata.Source 385 + } 386 + if hsmMetadata.CreatedAt != "" { 387 + annotations["hsm.j5t.io/created-at"] = hsmMetadata.CreatedAt 388 + } 389 + } 390 + 391 + // isValidKubernetesLabelKey validates a Kubernetes label key 392 + func (r *HSMSecretReconciler) isValidKubernetesLabelKey(key string) bool { 393 + // Basic validation - more comprehensive validation could be added 394 + if len(key) == 0 || len(key) > 63 { 395 + return false 396 + } 397 + // Should start and end with alphanumeric, can contain alphanumeric, dash, underscore, and dot 398 + for i, char := range key { 399 + isAlphaNumeric := (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') 400 + isAllowedSymbol := char == '-' || char == '_' || char == '.' 401 + 402 + if !isAlphaNumeric && !isAllowedSymbol { 403 + return false 404 + } 405 + if (i == 0 || i == len(key)-1) && !isAlphaNumeric { 406 + return false 407 + } 408 + } 409 + return true 410 + } 411 + 412 + // isValidKubernetesLabelValue validates a Kubernetes label value 413 + func (r *HSMSecretReconciler) isValidKubernetesLabelValue(value string) bool { 414 + // Basic validation 415 + if len(value) > 63 { 416 + return false 417 + } 418 + if len(value) == 0 { 419 + return true // Empty values are allowed 420 + } 421 + // Should start and end with alphanumeric, can contain alphanumeric, dash, underscore, and dot 422 + for i, char := range value { 423 + isAlphaNumeric := (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') 424 + isAllowedSymbol := char == '-' || char == '_' || char == '.' 425 + 426 + if !isAlphaNumeric && !isAllowedSymbol { 427 + return false 428 + } 429 + if (i == 0 || i == len(value)-1) && !isAlphaNumeric { 430 + return false 431 + } 432 + } 433 + return true 315 434 } 316 435 317 436 // convertHSMDataToSecretData converts HSM data format to Kubernetes Secret data format
+2 -3
internal/hsm/client.go
··· 37 37 38 38 // SecretMetadata contains metadata about an HSM secret 39 39 type SecretMetadata struct { 40 - Label string `json:"label,omitempty"` 41 40 Description string `json:"description,omitempty"` 42 - Tags map[string]string `json:"tags,omitempty"` 41 + Labels map[string]string `json:"labels,omitempty"` 43 42 Format string `json:"format,omitempty"` 44 - DataType SecretDataType `json:"dataType,omitempty"` 43 + DataType string `json:"dataType,omitempty"` 45 44 CreatedAt string `json:"createdAt,omitempty"` 46 45 Source string `json:"source,omitempty"` 47 46 }
+2 -2
internal/sync/manager.go
··· 172 172 // Get metadata to extract version (if available) 173 173 metadata, err := grpcClient.ReadMetadata(ctx, secretPath) 174 174 if err == nil && metadata != nil { 175 - if versionStr, exists := metadata.Tags["sync.version"]; exists { 175 + if versionStr, exists := metadata.Labels["sync.version"]; exists { 176 176 if version, parseErr := parseVersion(versionStr); parseErr == nil { 177 177 result.Version = version 178 178 } ··· 279 279 280 280 // Write data with updated version metadata 281 281 metadata := &hsm.SecretMetadata{ 282 - Tags: map[string]string{ 282 + Labels: map[string]string{ 283 283 "sync.version": fmt.Sprintf("%d", time.Now().Unix()), 284 284 "sync.primary": primaryDevice, 285 285 "sync.timestamp": time.Now().Format(time.RFC3339),
+127 -8
web/app.js
··· 41 41 return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`); 42 42 } 43 43 44 - async createSecret(secretName, data) { 44 + async createSecret(secretName, data, metadata = null) { 45 + const requestBody = { data }; 46 + if (metadata) { 47 + requestBody.metadata = metadata; 48 + } 45 49 return this.request(`/hsm/secrets/${encodeURIComponent(secretName)}`, { 46 50 method: 'POST', 47 - body: JSON.stringify({ data }) 51 + body: JSON.stringify(requestBody) 48 52 }); 49 53 } 50 54 ··· 64 68 65 69 init() { 66 70 this.kvPairCounter = 0; 71 + this.labelPairCounter = 0; 67 72 this.setupEventListeners(); 68 73 this.loadInitialData(); 69 74 this.initializeCreateForm(); ··· 72 77 initializeCreateForm() { 73 78 // Add initial empty key-value pair to the form 74 79 this.addKeyValuePair(); 80 + // Add initial empty label pair to the metadata form 81 + this.addLabelPair(); 75 82 } 76 83 77 84 setupEventListeners() { ··· 207 214 kvPairs.innerHTML = ''; 208 215 this.kvPairCounter = 0; 209 216 this.addKeyValuePair(); // Add one empty pair 217 + 218 + // Reset label pairs and advanced section 219 + const labelPairs = document.getElementById('labelPairs'); 220 + labelPairs.innerHTML = ''; 221 + this.labelPairCounter = 0; 222 + this.addLabelPair(); // Add one empty label pair 223 + 224 + // Close advanced section 225 + const advancedContent = document.getElementById('advancedContent'); 226 + const advancedToggle = document.querySelector('.advanced-toggle'); 227 + advancedContent.classList.remove('show'); 228 + advancedToggle.classList.remove('expanded'); 210 229 } 211 230 212 231 hideViewSection() { ··· 274 293 return data; 275 294 } 276 295 296 + toggleAdvanced() { 297 + const content = document.getElementById('advancedContent'); 298 + const toggle = document.querySelector('.advanced-toggle'); 299 + 300 + content.classList.toggle('show'); 301 + toggle.classList.toggle('expanded'); 302 + } 303 + 304 + addLabelPair(key = '', value = '') { 305 + const labelPairs = document.getElementById('labelPairs'); 306 + 307 + const pairId = this.labelPairCounter++; 308 + const pairDiv = document.createElement('div'); 309 + pairDiv.className = 'tag-pair'; 310 + pairDiv.id = `labelPair${pairId}`; 311 + 312 + pairDiv.innerHTML = ` 313 + <input type="text" name="labelKey${pairId}" placeholder="Label key (e.g., app, environment)" value="${this.escapeHtml(key)}"> 314 + <input type="text" name="labelValue${pairId}" placeholder="Label value (e.g., backend, production)" value="${this.escapeHtml(value)}"> 315 + <button type="button" class="btn btn-remove btn-small" onclick="ui.removeLabelPair('labelPair${pairId}')" title="Remove this label"> 316 + 317 + </button> 318 + `; 319 + 320 + labelPairs.appendChild(pairDiv); 321 + 322 + // Focus on the key input for new pairs (but not during initial load) 323 + if (!key && labelPairs.children.length > 1) { 324 + pairDiv.querySelector('input[name^="labelKey"]').focus(); 325 + } 326 + } 327 + 328 + removeLabelPair(pairId) { 329 + const labelPairs = document.getElementById('labelPairs'); 330 + const pairElement = document.getElementById(pairId); 331 + 332 + // Don't allow removing the last pair 333 + if (labelPairs.children.length <= 1) { 334 + return; 335 + } 336 + 337 + if (pairElement) { 338 + pairElement.remove(); 339 + } 340 + } 341 + 342 + collectLabelPairs() { 343 + const labelPairs = document.getElementById('labelPairs'); 344 + const pairs = labelPairs.querySelectorAll('.tag-pair'); 345 + const labels = {}; 346 + 347 + for (const pair of pairs) { 348 + const keyInput = pair.querySelector('input[name^="labelKey"]'); 349 + const valueInput = pair.querySelector('input[name^="labelValue"]'); 350 + 351 + if (keyInput && valueInput) { 352 + const key = keyInput.value.trim(); 353 + const value = valueInput.value.trim(); 354 + 355 + if (key && value) { 356 + labels[key] = value; 357 + } 358 + } 359 + } 360 + 361 + return labels; 362 + } 363 + 364 + collectMetadata() { 365 + const description = document.getElementById('metadataDescription').value.trim(); 366 + const format = document.getElementById('metadataFormat').value.trim(); 367 + const dataType = document.getElementById('metadataDataType').value.trim(); 368 + const source = document.getElementById('metadataSource').value.trim(); 369 + const labels = this.collectLabelPairs(); 370 + 371 + // Only return metadata if at least one field is filled 372 + if (!description && !format && !dataType && !source && Object.keys(labels).length === 0) { 373 + return null; 374 + } 375 + 376 + const metadata = {}; 377 + if (description) metadata.description = description; 378 + if (format) metadata.format = format; 379 + if (dataType) metadata.data_type = dataType; 380 + if (source) metadata.source = source; 381 + if (Object.keys(labels).length > 0) metadata.labels = labels; 382 + 383 + // Add creation timestamp 384 + metadata.created_at = new Date().toISOString(); 385 + 386 + return metadata; 387 + } 388 + 277 389 async handleCreateSecret(event) { 278 390 event.preventDefault(); 279 391 ··· 296 408 this.showError(messageElement, 'At least one key-value pair is required'); 297 409 return; 298 410 } 411 + 412 + // Collect metadata if any is provided 413 + const metadata = this.collectMetadata(); 299 414 300 415 // Validate key names (no spaces, no special chars except underscore) 301 416 for (const key of Object.keys(secretData)) { ··· 305 420 } 306 421 } 307 422 423 + // Get submit button and store original text 424 + const submitBtn = event.target.querySelector('button[type="submit"]'); 425 + const originalText = submitBtn.textContent; 426 + 308 427 try { 309 428 // Show loading state 310 - const submitBtn = event.target.querySelector('button[type="submit"]'); 311 - const originalText = submitBtn.textContent; 312 429 submitBtn.textContent = 'Creating...'; 313 430 submitBtn.disabled = true; 314 431 315 - await this.api.createSecret(secretName, secretData); 432 + await this.api.createSecret(secretName, secretData, metadata); 316 433 317 434 this.showSuccess(messageElement, `Secret "${secretName}" created successfully!`); 318 435 ··· 327 444 this.showError(messageElement, `Failed to create secret: ${error.message}`); 328 445 } finally { 329 446 // Restore button state 330 - const submitBtn = event.target.querySelector('button[type="submit"]'); 331 - submitBtn.textContent = 'Create Secret'; 447 + submitBtn.textContent = originalText; 332 448 submitBtn.disabled = false; 333 449 } 334 450 } ··· 394 510 395 511 window.addEventListener('DOMContentLoaded', () => { 396 512 ui = new HSMSecretsUI(); 513 + // Expose ui object globally for onclick handlers 514 + window.ui = ui; 397 515 }); 398 516 399 517 // Expose functions globally for onclick handlers 400 518 window.refreshSecrets = () => ui.refreshSecrets(); 401 519 window.showCreateForm = () => ui.showCreateForm(); 402 520 window.hideCreateForm = () => ui.hideCreateForm(); 403 - window.hideViewSection = () => ui.hideViewSection(); 521 + window.hideViewSection = () => ui.hideViewSection(); 522 +
+45 -310
web/index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>HSM Secrets Manager</title> 7 - <style> 8 - * { 9 - box-sizing: border-box; 10 - margin: 0; 11 - padding: 0; 12 - } 13 - 14 - body { 15 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 16 - background-color: #f5f5f5; 17 - color: #333; 18 - } 19 - 20 - .container { 21 - max-width: 1200px; 22 - margin: 0 auto; 23 - padding: 20px; 24 - } 25 - 26 - .header { 27 - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 28 - color: white; 29 - padding: 30px; 30 - border-radius: 10px; 31 - margin-bottom: 30px; 32 - text-align: center; 33 - } 34 - 35 - .header h1 { 36 - font-size: 2.5em; 37 - margin-bottom: 10px; 38 - } 39 - 40 - .header p { 41 - font-size: 1.1em; 42 - opacity: 0.9; 43 - } 44 - 45 - .section { 46 - background: white; 47 - border-radius: 10px; 48 - padding: 25px; 49 - margin-bottom: 25px; 50 - box-shadow: 0 2px 10px rgba(0,0,0,0.1); 51 - } 52 - 53 - .section h2 { 54 - color: #333; 55 - margin-bottom: 20px; 56 - font-size: 1.5em; 57 - border-bottom: 2px solid #667eea; 58 - padding-bottom: 10px; 59 - } 60 - 61 - .form-group { 62 - margin-bottom: 20px; 63 - } 64 - 65 - .form-group label { 66 - display: block; 67 - margin-bottom: 5px; 68 - font-weight: 600; 69 - color: #555; 70 - } 71 - 72 - .form-group input, .form-group textarea { 73 - width: 100%; 74 - padding: 12px; 75 - border: 2px solid #e1e5e9; 76 - border-radius: 6px; 77 - font-size: 14px; 78 - transition: border-color 0.3s ease; 79 - } 80 - 81 - .form-group input:focus, .form-group textarea:focus { 82 - outline: none; 83 - border-color: #667eea; 84 - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); 85 - } 86 - 87 - .form-group textarea { 88 - resize: vertical; 89 - min-height: 120px; 90 - font-family: Monaco, 'Cascadia Code', Consolas, monospace; 91 - } 92 - 93 - .kv-pair { 94 - display: flex; 95 - gap: 10px; 96 - margin-bottom: 10px; 97 - align-items: center; 98 - } 99 - 100 - .kv-pair input[type="text"] { 101 - flex: 1; 102 - } 103 - 104 - .kv-pair input[name*="key"] { 105 - flex: 0 0 30%; 106 - } 107 - 108 - .kv-pair input[name*="value"] { 109 - flex: 0 0 60%; 110 - } 111 - 112 - .btn-small { 113 - padding: 0; 114 - font-size: 14px; 115 - width: 28px; 116 - height: 28px; 117 - display: flex; 118 - align-items: center; 119 - justify-content: center; 120 - line-height: 1; 121 - } 122 - 123 - .btn-add { 124 - background: #38a169; 125 - } 126 - 127 - .btn-add:hover { 128 - background: #2f855a; 129 - box-shadow: 0 4px 12px rgba(56, 161, 105, 0.3); 130 - } 131 - 132 - .btn-remove { 133 - background: #e53e3e; 134 - } 135 - 136 - .btn-remove:hover { 137 - background: #c53030; 138 - box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); 139 - } 140 - 141 - .kv-container { 142 - border: 2px dashed #e2e8f0; 143 - border-radius: 8px; 144 - padding: 15px; 145 - background: #f8f9fa; 146 - } 147 - 148 - .kv-header { 149 - display: flex; 150 - justify-content: space-between; 151 - align-items: center; 152 - margin-bottom: 15px; 153 - font-weight: 600; 154 - color: #4a5568; 155 - } 156 - 157 - .add-first-pair { 158 - text-align: center; 159 - color: #666; 160 - padding: 20px; 161 - } 162 - 163 - .btn { 164 - background: #667eea; 165 - color: white; 166 - border: none; 167 - padding: 20px 20px; 168 - border-radius: 6px; 169 - cursor: pointer; 170 - font-size: 14px; 171 - font-weight: 600; 172 - transition: all 0.3s ease; 173 - text-decoration: none; 174 - } 175 - 176 - .btn:hover { 177 - background: #5a67d8; 178 - transform: translateY(-2px); 179 - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); 180 - } 181 - 182 - .btn-danger { 183 - background: #e53e3e; 184 - } 185 - 186 - .btn-danger:hover { 187 - background: #c53030; 188 - box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); 189 - } 190 - 191 - .btn-secondary { 192 - background: #718096; 193 - } 194 - 195 - .btn-secondary:hover { 196 - background: #4a5568; 197 - box-shadow: 0 4px 12px rgba(113, 128, 150, 0.3); 198 - } 199 - 200 - .secrets-list { 201 - margin-top: 20px; 202 - } 203 - 204 - .secret-item { 205 - background: #f8f9fa; 206 - border: 1px solid #e2e8f0; 207 - border-radius: 6px; 208 - padding: 15px; 209 - margin-bottom: 10px; 210 - display: flex; 211 - justify-content: between; 212 - align-items: center; 213 - } 214 - 215 - .secret-name { 216 - font-weight: 600; 217 - color: #2d3748; 218 - flex-grow: 1; 219 - } 220 - 221 - .secret-actions { 222 - display: flex; 223 - gap: 10px; 224 - } 225 - 226 - .loading { 227 - text-align: center; 228 - padding: 20px; 229 - color: #666; 230 - } 231 - 232 - .error { 233 - background: #fed7d7; 234 - border: 1px solid #feb2b2; 235 - color: #c53030; 236 - padding: 15px; 237 - border-radius: 6px; 238 - margin-bottom: 20px; 239 - } 240 - 241 - .success { 242 - background: #c6f6d5; 243 - border: 1px solid #9ae6b4; 244 - color: #22543d; 245 - padding: 15px; 246 - border-radius: 6px; 247 - margin-bottom: 20px; 248 - } 249 - 250 - .json-preview { 251 - background: #1a202c; 252 - color: #e2e8f0; 253 - padding: 15px; 254 - border-radius: 6px; 255 - font-family: Monaco, 'Cascadia Code', Consolas, monospace; 256 - font-size: 12px; 257 - white-space: pre-wrap; 258 - max-height: 300px; 259 - overflow-y: auto; 260 - margin-top: 10px; 261 - } 262 - 263 - .stats { 264 - display: grid; 265 - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 266 - gap: 20px; 267 - margin-bottom: 30px; 268 - } 269 - 270 - .stat-card { 271 - background: white; 272 - padding: 20px; 273 - border-radius: 10px; 274 - text-align: center; 275 - box-shadow: 0 2px 10px rgba(0,0,0,0.1); 276 - } 277 - 278 - .stat-number { 279 - font-size: 2em; 280 - font-weight: bold; 281 - color: #667eea; 282 - margin-bottom: 5px; 283 - } 284 - 285 - .stat-label { 286 - color: #666; 287 - font-size: 0.9em; 288 - } 289 - 290 - .toolbar { 291 - display: flex; 292 - gap: 10px; 293 - margin-bottom: 20px; 294 - align-items: center; 295 - } 296 - 297 - @media (max-width: 768px) { 298 - .container { 299 - padding: 10px; 300 - } 301 - 302 - .header h1 { 303 - font-size: 1.8em; 304 - } 305 - 306 - .secret-item { 307 - flex-direction: column; 308 - align-items: stretch; 309 - } 310 - 311 - .secret-actions { 312 - margin-top: 10px; 313 - justify-content: flex-end; 314 - } 315 - } 316 - </style> 7 + <link rel="stylesheet" href="styles.css"> 317 8 </head> 318 9 <body> 319 10 <div class="container"> ··· 364 55 </div> 365 56 <div id="kvPairs"> 366 57 <!-- Initial key-value pair will be added by JavaScript --> 58 + </div> 59 + </div> 60 + </div> 61 + 62 + <div class="advanced-section"> 63 + <button type="button" class="advanced-toggle" onclick="ui.toggleAdvanced()"> 64 + <span>🔧 Advanced Metadata (Optional)</span> 65 + <span class="arrow">▼</span> 66 + </button> 67 + <div id="advancedContent" class="advanced-content"> 68 + 69 + <div class="metadata-field"> 70 + <label for="metadataDescription">Description</label> 71 + <textarea id="metadataDescription" name="metadataDescription" placeholder="Description of this secret's purpose"></textarea> 72 + </div> 73 + 74 + <div class="metadata-field"> 75 + <label for="metadataFormat">Format</label> 76 + <input type="text" id="metadataFormat" name="metadataFormat" placeholder="e.g., json, yaml, text"> 77 + </div> 78 + 79 + <div class="metadata-field"> 80 + <label for="metadataDataType">Data Type</label> 81 + <input type="text" id="metadataDataType" name="metadataDataType" placeholder="e.g., credentials, config, certificate"> 82 + </div> 83 + 84 + <div class="metadata-field"> 85 + <label for="metadataSource">Source</label> 86 + <input type="text" id="metadataSource" name="metadataSource" placeholder="Source system or application"> 87 + </div> 88 + 89 + <div class="metadata-field"> 90 + <label>Labels</label> 91 + <div class="tags-container"> 92 + <div class="tags-header"> 93 + <span>Kubernetes Labels (will be applied to Secret)</span> 94 + <button type="button" class="btn btn-add btn-small" onclick="ui.addLabelPair()" title="Add new label"> 95 + 96 + </button> 97 + </div> 98 + <div id="labelPairs"> 99 + <!-- Label pairs will be added by JavaScript --> 100 + </div> 101 + </div> 367 102 </div> 368 103 </div> 369 104 </div>
+411
web/styles.css
··· 1 + * { 2 + box-sizing: border-box; 3 + margin: 0; 4 + padding: 0; 5 + } 6 + 7 + body { 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 9 + background-color: #f5f5f5; 10 + color: #333; 11 + } 12 + 13 + .container { 14 + max-width: 1200px; 15 + margin: 0 auto; 16 + padding: 20px; 17 + } 18 + 19 + .header { 20 + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 21 + color: white; 22 + padding: 30px; 23 + border-radius: 10px; 24 + margin-bottom: 30px; 25 + text-align: center; 26 + } 27 + 28 + .header h1 { 29 + font-size: 2.5em; 30 + margin-bottom: 10px; 31 + } 32 + 33 + .header p { 34 + font-size: 1.1em; 35 + opacity: 0.9; 36 + } 37 + 38 + .section { 39 + background: white; 40 + border-radius: 10px; 41 + padding: 25px; 42 + margin-bottom: 25px; 43 + box-shadow: 0 2px 10px rgba(0,0,0,0.1); 44 + } 45 + 46 + .section h2 { 47 + color: #333; 48 + margin-bottom: 20px; 49 + font-size: 1.5em; 50 + border-bottom: 2px solid #667eea; 51 + padding-bottom: 10px; 52 + } 53 + 54 + .form-group { 55 + margin-bottom: 20px; 56 + } 57 + 58 + .form-group label { 59 + display: block; 60 + margin-bottom: 5px; 61 + font-weight: 600; 62 + color: #555; 63 + } 64 + 65 + .form-group input, .form-group textarea { 66 + width: 100%; 67 + padding: 12px; 68 + border: 2px solid #e1e5e9; 69 + border-radius: 6px; 70 + font-size: 14px; 71 + transition: border-color 0.3s ease; 72 + } 73 + 74 + .form-group input:focus, .form-group textarea:focus { 75 + outline: none; 76 + border-color: #667eea; 77 + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); 78 + } 79 + 80 + .form-group textarea { 81 + resize: vertical; 82 + min-height: 120px; 83 + font-family: Monaco, 'Cascadia Code', Consolas, monospace; 84 + } 85 + 86 + .kv-pair { 87 + display: flex; 88 + gap: 10px; 89 + margin-bottom: 10px; 90 + align-items: center; 91 + } 92 + 93 + .kv-pair input[type="text"] { 94 + flex: 1; 95 + } 96 + 97 + .kv-pair input[name*="key"] { 98 + flex: 0 0 30%; 99 + } 100 + 101 + .kv-pair input[name*="value"] { 102 + flex: 0 0 60%; 103 + } 104 + 105 + .btn-small { 106 + padding: 0; 107 + font-size: 14px; 108 + width: 28px; 109 + height: 28px; 110 + display: flex; 111 + align-items: center; 112 + justify-content: center; 113 + line-height: 1; 114 + } 115 + 116 + .btn-add { 117 + background: #38a169; 118 + } 119 + 120 + .btn-add:hover { 121 + background: #2f855a; 122 + box-shadow: 0 4px 12px rgba(56, 161, 105, 0.3); 123 + } 124 + 125 + .btn-remove { 126 + background: #e53e3e; 127 + } 128 + 129 + .btn-remove:hover { 130 + background: #c53030; 131 + box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); 132 + } 133 + 134 + .kv-container { 135 + border: 2px dashed #e2e8f0; 136 + border-radius: 8px; 137 + padding: 15px; 138 + background: #f8f9fa; 139 + } 140 + 141 + .kv-header { 142 + display: flex; 143 + justify-content: space-between; 144 + align-items: center; 145 + margin-bottom: 15px; 146 + font-weight: 600; 147 + color: #4a5568; 148 + } 149 + 150 + .add-first-pair { 151 + text-align: center; 152 + color: #666; 153 + padding: 20px; 154 + } 155 + 156 + .advanced-section { 157 + margin-top: 20px; 158 + border: 2px solid #e2e8f0; 159 + border-radius: 8px; 160 + overflow: hidden; 161 + } 162 + 163 + .advanced-toggle { 164 + background: #f7fafc; 165 + border: none; 166 + width: 100%; 167 + padding: 15px; 168 + text-align: left; 169 + cursor: pointer; 170 + font-weight: 600; 171 + color: #4a5568; 172 + transition: background-color 0.2s ease; 173 + display: flex; 174 + justify-content: space-between; 175 + align-items: center; 176 + } 177 + 178 + .advanced-toggle:hover { 179 + background: #edf2f7; 180 + } 181 + 182 + .advanced-toggle .arrow { 183 + transition: transform 0.2s ease; 184 + font-size: 12px; 185 + } 186 + 187 + .advanced-toggle.expanded .arrow { 188 + transform: rotate(180deg); 189 + } 190 + 191 + .advanced-content { 192 + display: none; 193 + padding: 20px; 194 + background: white; 195 + border-top: 1px solid #e2e8f0; 196 + } 197 + 198 + .advanced-content.show { 199 + display: block; 200 + } 201 + 202 + .metadata-field { 203 + margin-bottom: 15px; 204 + } 205 + 206 + .metadata-field label { 207 + display: block; 208 + margin-bottom: 5px; 209 + font-weight: 600; 210 + color: #555; 211 + font-size: 14px; 212 + } 213 + 214 + .metadata-field input, .metadata-field textarea { 215 + width: 100%; 216 + padding: 10px; 217 + border: 2px solid #e1e5e9; 218 + border-radius: 6px; 219 + font-size: 14px; 220 + } 221 + 222 + .metadata-field textarea { 223 + resize: vertical; 224 + min-height: 60px; 225 + } 226 + 227 + .tags-container { 228 + border: 2px dashed #e2e8f0; 229 + border-radius: 8px; 230 + padding: 15px; 231 + background: #f8f9fa; 232 + } 233 + 234 + .tags-header { 235 + display: flex; 236 + justify-content: space-between; 237 + align-items: center; 238 + margin-bottom: 10px; 239 + font-weight: 600; 240 + color: #4a5568; 241 + font-size: 14px; 242 + } 243 + 244 + .tag-pair { 245 + display: flex; 246 + gap: 10px; 247 + margin-bottom: 8px; 248 + align-items: center; 249 + } 250 + 251 + .tag-pair input { 252 + flex: 1; 253 + padding: 8px; 254 + border: 1px solid #d1d5db; 255 + border-radius: 4px; 256 + font-size: 13px; 257 + } 258 + 259 + .btn { 260 + background: #667eea; 261 + color: white; 262 + border: none; 263 + padding: 20px 20px; 264 + border-radius: 6px; 265 + cursor: pointer; 266 + font-size: 14px; 267 + font-weight: 600; 268 + transition: all 0.3s ease; 269 + text-decoration: none; 270 + } 271 + 272 + .btn:hover { 273 + background: #5a67d8; 274 + transform: translateY(-2px); 275 + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); 276 + } 277 + 278 + .btn-danger { 279 + background: #e53e3e; 280 + } 281 + 282 + .btn-danger:hover { 283 + background: #c53030; 284 + box-shadow: 0 4px 12px rgba(229, 62, 62, 0.3); 285 + } 286 + 287 + .btn-secondary { 288 + background: #718096; 289 + } 290 + 291 + .btn-secondary:hover { 292 + background: #4a5568; 293 + box-shadow: 0 4px 12px rgba(113, 128, 150, 0.3); 294 + } 295 + 296 + .secrets-list { 297 + margin-top: 20px; 298 + } 299 + 300 + .secret-item { 301 + background: #f8f9fa; 302 + border: 1px solid #e2e8f0; 303 + border-radius: 6px; 304 + padding: 15px; 305 + margin-bottom: 10px; 306 + display: flex; 307 + justify-content: between; 308 + align-items: center; 309 + } 310 + 311 + .secret-name { 312 + font-weight: 600; 313 + color: #2d3748; 314 + flex-grow: 1; 315 + } 316 + 317 + .secret-actions { 318 + display: flex; 319 + gap: 10px; 320 + } 321 + 322 + .loading { 323 + text-align: center; 324 + padding: 20px; 325 + color: #666; 326 + } 327 + 328 + .error { 329 + background: #fed7d7; 330 + border: 1px solid #feb2b2; 331 + color: #c53030; 332 + padding: 15px; 333 + border-radius: 6px; 334 + margin-bottom: 20px; 335 + } 336 + 337 + .success { 338 + background: #c6f6d5; 339 + border: 1px solid #9ae6b4; 340 + color: #22543d; 341 + padding: 15px; 342 + border-radius: 6px; 343 + margin-bottom: 20px; 344 + } 345 + 346 + .json-preview { 347 + background: #1a202c; 348 + color: #e2e8f0; 349 + padding: 15px; 350 + border-radius: 6px; 351 + font-family: Monaco, 'Cascadia Code', Consolas, monospace; 352 + font-size: 12px; 353 + white-space: pre-wrap; 354 + max-height: 300px; 355 + overflow-y: auto; 356 + margin-top: 10px; 357 + } 358 + 359 + .stats { 360 + display: grid; 361 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 362 + gap: 20px; 363 + margin-bottom: 30px; 364 + } 365 + 366 + .stat-card { 367 + background: white; 368 + padding: 20px; 369 + border-radius: 10px; 370 + text-align: center; 371 + box-shadow: 0 2px 10px rgba(0,0,0,0.1); 372 + } 373 + 374 + .stat-number { 375 + font-size: 2em; 376 + font-weight: bold; 377 + color: #667eea; 378 + margin-bottom: 5px; 379 + } 380 + 381 + .stat-label { 382 + color: #666; 383 + font-size: 0.9em; 384 + } 385 + 386 + .toolbar { 387 + display: flex; 388 + gap: 10px; 389 + margin-bottom: 20px; 390 + align-items: center; 391 + } 392 + 393 + @media (max-width: 768px) { 394 + .container { 395 + padding: 10px; 396 + } 397 + 398 + .header h1 { 399 + font-size: 1.8em; 400 + } 401 + 402 + .secret-item { 403 + flex-direction: column; 404 + align-items: stretch; 405 + } 406 + 407 + .secret-actions { 408 + margin-top: 10px; 409 + justify-content: flex-end; 410 + } 411 + }