peaufinage
This commit is contained in:
parent
e2375fbba9
commit
d8cf61be47
@ -12,6 +12,7 @@
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"sweetalert2": "^11.26.3",
|
||||
"vue": "^3.4.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
sweetalert2:
|
||||
specifier: ^11.26.3
|
||||
version: 11.26.3
|
||||
vue:
|
||||
specifier: ^3.4.15
|
||||
version: 3.5.22(typescript@5.9.3)
|
||||
@ -1249,6 +1252,9 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sweetalert2@11.26.3:
|
||||
resolution: {integrity: sha512-VU0hGw/WfI9h7Mh+SCsDlWgtxDwWZ6ccqS7QcO8zEeWnwplN1GptcLstq76OluUBSLUza6ldvKd3558OhjpJ9A==}
|
||||
|
||||
tailwindcss@3.4.18:
|
||||
resolution: {integrity: sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@ -2524,6 +2530,8 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
sweetalert2@11.26.3: {}
|
||||
|
||||
tailwindcss@3.4.18:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
|
||||
39
src/App.vue
39
src/App.vue
@ -4,11 +4,23 @@ import type { VaultServer, VaultCredentials, VaultConnection } from './types'
|
||||
import ServerSelector from './components/ServerSelector.vue'
|
||||
import LoginForm from './components/LoginForm.vue'
|
||||
import Dashboard from './components/Dashboard.vue'
|
||||
import PolicyModal from './components/PolicyModal.vue'
|
||||
import { useSweetAlert } from './composables/useSweetAlert'
|
||||
import { usePolicyModal } from './composables/usePolicyModal'
|
||||
|
||||
const servers = ref<VaultServer[]>([])
|
||||
const selectedServer = ref<VaultServer | null>(null)
|
||||
const activeConnection = ref<VaultConnection | null>(null)
|
||||
|
||||
const { error } = useSweetAlert()
|
||||
const { modalState, closePolicyModal, showPolicyModal } = usePolicyModal()
|
||||
|
||||
// Test function for debugging
|
||||
const testPolicyModal = () => {
|
||||
console.log('🔍 Testing policy modal')
|
||||
showPolicyModal('secret/myapp/config', 'write', 'permission denied', 'Test Policy Modal')
|
||||
}
|
||||
|
||||
// Load servers from localStorage on mount
|
||||
onMounted(() => {
|
||||
const savedServers = localStorage.getItem('vaultServers')
|
||||
@ -51,11 +63,11 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
|
||||
try {
|
||||
// Verify login and get mount points
|
||||
const { vaultApi } = await import('./services/vaultApi')
|
||||
const mountPoints = await vaultApi.verifyLoginAndGetMounts(selectedServer.value, credentials)
|
||||
const { mountPoints, updatedCredentials } = await vaultApi.verifyLoginAndGetMounts(selectedServer.value, credentials)
|
||||
|
||||
activeConnection.value = {
|
||||
server: selectedServer.value,
|
||||
credentials,
|
||||
credentials: updatedCredentials, // Use the updated credentials with token
|
||||
isConnected: true,
|
||||
lastConnected: new Date(),
|
||||
mountPoints,
|
||||
@ -65,7 +77,7 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
|
||||
if (shouldSaveCredentials) {
|
||||
const serverIndex = servers.value.findIndex(s => s.id === selectedServer.value!.id)
|
||||
if (serverIndex !== -1) {
|
||||
servers.value[serverIndex].savedCredentials = credentials
|
||||
servers.value[serverIndex].savedCredentials = updatedCredentials // Save the updated credentials with token
|
||||
console.log('⚠️ Credentials saved to localStorage (insecure!)')
|
||||
}
|
||||
} else {
|
||||
@ -78,9 +90,9 @@ const handleLogin = async (credentials: VaultCredentials, shouldSaveCredentials:
|
||||
}
|
||||
|
||||
console.log(`✓ Logged in successfully. Found ${mountPoints.length} KV mount point(s).`)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert(`Login failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\n` + 'Please check your credentials and server configuration.')
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err)
|
||||
error(`Login failed: ${err instanceof Error ? err.message : 'Unknown error'}\n\nPlease check your credentials and server configuration.`)
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,6 +116,11 @@ const handleLogout = () => {
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 container mx-auto px-4 py-8">
|
||||
<!-- Debug button -->
|
||||
<div class="mb-4">
|
||||
<button class="btn btn-sm btn-secondary" @click="testPolicyModal">🔍 Test Policy Modal</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!activeConnection" class="grid md:grid-cols-2 gap-8">
|
||||
<!-- Server Selection -->
|
||||
<div>
|
||||
@ -130,5 +147,15 @@ const handleLogout = () => {
|
||||
<footer class="bg-base-300 border-t border-base-content/10">
|
||||
<div class="container mx-auto px-4 py-4 text-center text-sm opacity-70">Browser Vault GUI - An alternative frontend for HashiCorp Vault</div>
|
||||
</footer>
|
||||
|
||||
<!-- Policy Modal -->
|
||||
<PolicyModal
|
||||
:is-open="modalState.isOpen"
|
||||
:title="modalState.title"
|
||||
:path="modalState.path"
|
||||
:operation="modalState.operation"
|
||||
:original-error="modalState.originalError"
|
||||
@close="closePolicyModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import type { VaultConnection } from '../types'
|
||||
import { vaultApi, VaultError } from '../services/vaultApi'
|
||||
import PathSearch from './PathSearch.vue'
|
||||
import Settings from './Settings.vue'
|
||||
import SecretModal from './SecretModal.vue'
|
||||
@ -15,125 +14,19 @@ const emit = defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const selectedMountPoint = ref('')
|
||||
const secretPath = ref('')
|
||||
const secretData = ref<Record<string, unknown> | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const showSearch = ref(true) // Show search by default
|
||||
const showSecretModal = ref(false)
|
||||
const selectedSecretPath = ref('')
|
||||
|
||||
// Select first mount point by default
|
||||
onMounted(() => {
|
||||
if (props.connection.mountPoints && props.connection.mountPoints.length > 0) {
|
||||
selectedMountPoint.value = props.connection.mountPoints[0].path
|
||||
}
|
||||
})
|
||||
|
||||
const handleReadSecret = async (path?: string) => {
|
||||
let pathToRead = path
|
||||
|
||||
if (!pathToRead) {
|
||||
// Build path from mount point + secret path
|
||||
if (!selectedMountPoint.value || !secretPath.value) {
|
||||
alert('Please select a mount point and enter a secret path')
|
||||
return
|
||||
}
|
||||
pathToRead = `${selectedMountPoint.value}/${secretPath.value}`
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
secretData.value = null
|
||||
|
||||
try {
|
||||
const data = await vaultApi.readSecret(props.connection.server, props.connection.credentials, pathToRead)
|
||||
|
||||
if (data) {
|
||||
secretData.value = data
|
||||
// Update the form fields if this was a manual read
|
||||
if (!path) {
|
||||
// Keep the current mount point and path
|
||||
}
|
||||
} else {
|
||||
alert('Secret not found or empty.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading secret:', error)
|
||||
|
||||
if (error instanceof VaultError) {
|
||||
let message = `Failed to read secret: ${error.message}`
|
||||
if (error.statusCode) {
|
||||
message += ` (HTTP ${error.statusCode})`
|
||||
}
|
||||
if (error.errors && error.errors.length > 0) {
|
||||
message += `\n\nDetails:\n${error.errors.join('\n')}`
|
||||
}
|
||||
|
||||
if (error.statusCode === 403) {
|
||||
message += '\n\nYou may not have permission to read this secret.'
|
||||
} else if (error.statusCode === 404) {
|
||||
message = 'Secret not found at this path.'
|
||||
} else if (error.message.includes('CORS')) {
|
||||
message += '\n\nCORS error: Make sure your Vault server is configured to allow requests from this origin.'
|
||||
}
|
||||
|
||||
alert(message)
|
||||
} else {
|
||||
alert('Failed to read secret. Check console for details.')
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPath = (path: string) => {
|
||||
// Parse the path to extract mount point and secret path
|
||||
const mountPoints = props.connection.mountPoints || []
|
||||
let foundMount = ''
|
||||
let remainingPath = path
|
||||
|
||||
// Find the longest matching mount point
|
||||
for (const mount of mountPoints) {
|
||||
const mountPath = mount.path + '/'
|
||||
if (path.startsWith(mountPath)) {
|
||||
if (mountPath.length > foundMount.length) {
|
||||
foundMount = mount.path
|
||||
remainingPath = path.substring(mountPath.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMount) {
|
||||
selectedMountPoint.value = foundMount
|
||||
secretPath.value = remainingPath
|
||||
}
|
||||
|
||||
// Open secret in modal instead of inline
|
||||
// Open the selected secret in the modal
|
||||
selectedSecretPath.value = path
|
||||
showSecretModal.value = true
|
||||
}
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' && !isLoading.value) {
|
||||
handleViewSecret()
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSecret = () => {
|
||||
if (!selectedMountPoint.value || !secretPath.value) {
|
||||
alert('Please select a mount point and enter a secret path')
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = `${selectedMountPoint.value}/${secretPath.value}`
|
||||
selectedSecretPath.value = fullPath
|
||||
showSecretModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="container mx-auto p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
@ -152,10 +45,6 @@ const handleViewSecret = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary btn-sm" @click="showSearch = !showSearch">
|
||||
<i :class="showSearch ? 'mdi mdi-magnify-close' : 'mdi mdi-magnify'" class="mr-2" />
|
||||
{{ showSearch ? 'Hide Search' : 'Show Search' }}
|
||||
</button>
|
||||
<button class="btn btn-sm" @click="showSettings = true">
|
||||
<i class="mdi mdi-cog mr-2" />
|
||||
Settings
|
||||
@ -171,108 +60,12 @@ const handleViewSecret = () => {
|
||||
|
||||
<!-- Search Component -->
|
||||
<PathSearch
|
||||
v-if="showSearch"
|
||||
:server="connection.server"
|
||||
:credentials="connection.credentials"
|
||||
:mount-points="connection.mountPoints"
|
||||
@select-path="handleSelectPath"
|
||||
/>
|
||||
|
||||
<!-- Secret Browser -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="text-xl font-bold mb-4">Browse Secrets</h3>
|
||||
|
||||
<!-- Mount Point Selector -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Mount Point</span>
|
||||
</label>
|
||||
<select v-model="selectedMountPoint" class="select select-bordered w-full" :disabled="isLoading">
|
||||
<option value="">Select a mount point...</option>
|
||||
<option v-for="mount in connection.mountPoints" :key="mount.path" :value="mount.path">{{ mount.path }}/ ({{ mount.type }} v2)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Secret Path Input -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Secret Path</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<span class="join-item bg-base-300 px-3 py-2 text-sm font-mono border border-base-300"> {{ selectedMountPoint || 'mount' }}/ </span>
|
||||
<input
|
||||
v-model="secretPath"
|
||||
type="text"
|
||||
placeholder="data/myapp/config"
|
||||
class="input input-bordered join-item flex-1"
|
||||
:disabled="isLoading || !selectedMountPoint"
|
||||
@keypress="handleKeyPress"
|
||||
/>
|
||||
<button class="btn btn-primary join-item" :disabled="!selectedMountPoint || !secretPath" @click="handleViewSecret()">
|
||||
<i class="mdi mdi-eye mr-2" />
|
||||
View Secret
|
||||
</button>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">
|
||||
Full path:
|
||||
{{ selectedMountPoint ? `${selectedMountPoint}/${secretPath || 'path'}` : 'Select mount point first' }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div v-if="!showSearch" class="alert alert-info mt-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<h4 class="font-bold">Browse Secrets</h4>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Select a mount point from the detected KV secret engines</li>
|
||||
<li>Enter the secret path (without the mount point prefix)</li>
|
||||
<li>
|
||||
Example: Mount
|
||||
<code class="bg-base-200 px-1 rounded">secret</code> + Path
|
||||
<code class="bg-base-200 px-1 rounded">data/myapp/config</code>
|
||||
</li>
|
||||
<li>Use Search (shown above) to find secrets across all mount points</li>
|
||||
<li><strong>Security:</strong> Secret data is never cached - always fetched fresh</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Info -->
|
||||
<div class="alert mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-xs">
|
||||
<h4 class="font-semibold">Security & Caching</h4>
|
||||
<p class="mt-1 flex items-center gap-2">
|
||||
<i class="mdi mdi-lock text-success" />
|
||||
<strong>Secret data is NEVER cached</strong> - always fetched fresh for security.
|
||||
</p>
|
||||
<p class="mt-1 flex items-center gap-2">
|
||||
<i class="mdi mdi-folder text-info" />
|
||||
Directory listings are cached to improve search performance.
|
||||
</p>
|
||||
<p class="mt-1 flex items-center gap-2">
|
||||
<i class="mdi mdi-key text-warning" />
|
||||
All requests include the
|
||||
<code class="bg-base-200 px-1 rounded">X-Vault-Token</code> header for authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<Settings v-if="showSettings" @close="showSettings = false" />
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { VaultServer, VaultCredentials } from '../types'
|
||||
import { useSweetAlert } from '../composables/useSweetAlert'
|
||||
|
||||
interface Props {
|
||||
server: VaultServer
|
||||
@ -11,6 +12,8 @@ const emit = defineEmits<{
|
||||
login: [credentials: VaultCredentials, saveCredentials: boolean]
|
||||
}>()
|
||||
|
||||
const { error } = useSweetAlert()
|
||||
|
||||
const authMethod = ref<'token' | 'userpass' | 'ldap'>('token')
|
||||
const token = ref('')
|
||||
const username = ref('')
|
||||
@ -74,9 +77,9 @@ const performLogin = async () => {
|
||||
|
||||
try {
|
||||
await emit('login', credentials, saveCredentials.value)
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
alert('Login failed. Please check your credentials.')
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
error('Login failed. Please check your credentials.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { VaultServer, VaultCredentials, MountPoint } from '../types'
|
||||
import { vaultApi, type SearchResult } from '../services/vaultApi'
|
||||
import { VaultError } from '../services/vaultClient'
|
||||
import { useSweetAlert } from '../composables/useSweetAlert'
|
||||
import { usePolicyModal } from '../composables/usePolicyModal'
|
||||
|
||||
interface Props {
|
||||
server: VaultServer
|
||||
@ -14,10 +17,14 @@ const emit = defineEmits<{
|
||||
selectPath: [path: string]
|
||||
}>()
|
||||
|
||||
const { warning, error } = useSweetAlert()
|
||||
const { showPolicyModal } = usePolicyModal()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const results = ref<SearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
const searchTime = ref<number | null>(null)
|
||||
const searchProgress = ref({ current: 0, total: 0 })
|
||||
|
||||
// Debug: Log mount points
|
||||
console.log('PathSearch - mountPoints:', props.mountPoints)
|
||||
@ -28,24 +35,35 @@ const mountPointsAvailable = computed(() => {
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchTerm.value.trim()) {
|
||||
alert('Please enter a search term')
|
||||
warning('Please enter a search term')
|
||||
return
|
||||
}
|
||||
|
||||
if (!mountPointsAvailable.value) {
|
||||
alert('No mount points available. Please ensure you are connected to Vault.')
|
||||
warning(
|
||||
'No mount points available. Please ensure you are connected to Vault. If you are connected, ensure you have some permissions to see some secrets!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
isSearching.value = true
|
||||
results.value = []
|
||||
searchTime.value = null
|
||||
searchProgress.value = { current: 0, total: 0 }
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
try {
|
||||
// Always search across all mount points
|
||||
const searchResults = await vaultApi.searchAllMounts(props.server, props.credentials, props.mountPoints!, searchTerm.value)
|
||||
// Always search across all mount points with progress tracking
|
||||
const searchResults = await vaultApi.searchAllMounts(
|
||||
props.server,
|
||||
props.credentials,
|
||||
props.mountPoints!,
|
||||
searchTerm.value,
|
||||
(pathsSearched: number) => {
|
||||
searchProgress.value.current += pathsSearched
|
||||
}
|
||||
)
|
||||
|
||||
const endTime = performance.now()
|
||||
searchTime.value = endTime - startTime
|
||||
@ -56,11 +74,17 @@ const handleSearch = async () => {
|
||||
}
|
||||
|
||||
console.log(results.value.length)
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
alert('Search failed. Check console for details.')
|
||||
} catch (err) {
|
||||
console.error('Search error:', err)
|
||||
if (err instanceof VaultError && err.statusCode === 403) {
|
||||
// Use a generic path for search operations since we're searching across multiple mounts
|
||||
showPolicyModal('*/metadata/*', 'list', err.message, 'Permission Denied - Cannot Search Secrets')
|
||||
} else {
|
||||
error('Search failed. Check console for details.')
|
||||
}
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
searchProgress.value = { current: 0, total: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +149,12 @@ const handleKeyPress = (event: KeyboardEvent) => {
|
||||
<!-- Search Progress -->
|
||||
<div v-if="isSearching" class="alert mt-4">
|
||||
<span class="loading loading-spinner" />
|
||||
<span>Searching recursively... This may take a moment.</span>
|
||||
<div class="flex flex-col">
|
||||
<span>Searching recursively... This may take a moment.</span>
|
||||
<span v-if="searchProgress.current > 0" class="text-sm opacity-70 mt-1">
|
||||
Paths searched: <strong>{{ searchProgress.current }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
|
||||
185
src/components/PolicyModal.vue
Normal file
185
src/components/PolicyModal.vue
Normal file
@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="modal modal-open">
|
||||
<div class="modal-box w-11/12 max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-lg text-error flex items-center">
|
||||
<i class="mdi mdi-shield-alert mr-2"></i>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" @click="close">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="space-y-4">
|
||||
<!-- Error message -->
|
||||
<div class="alert alert-error">
|
||||
<i class="mdi mdi-alert-circle"></i>
|
||||
<span>{{ originalError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Policy guidance -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold text-base">Required Vault Policy Permissions:</h4>
|
||||
|
||||
<p class="text-sm text-base-content/70">You need the following permissions in your Vault policy to {{ policyContent.description }}:</p>
|
||||
|
||||
<pre><code>{{ policyContent.combinedCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information"></i>
|
||||
<div>
|
||||
<div class="font-semibold">Next Steps:</div>
|
||||
<div class="text-sm mt-1">Copy the policy code above and ask your Vault administrator to add these permissions to your policy.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary" @click="close">
|
||||
<i class="mdi mdi-check mr-2"></i>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
path: string
|
||||
operation: 'read' | 'write' | 'delete' | 'list'
|
||||
originalError: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Permission Denied',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
newValue => {
|
||||
console.log('🔍 PolicyModal isOpen changed:', newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.path, props.operation, props.originalError],
|
||||
([path, operation, originalError]) => {
|
||||
console.log('🔍 PolicyModal props changed:', { path, operation, originalError })
|
||||
}
|
||||
)
|
||||
|
||||
// Generate policy examples directly
|
||||
const policyContent = computed(() => {
|
||||
const pathParts = props.path.split('/')
|
||||
const mountPoint = pathParts[0]
|
||||
const secretPath = pathParts.slice(1).join('/')
|
||||
|
||||
let description: string
|
||||
let examples: string[] = []
|
||||
|
||||
switch (props.operation) {
|
||||
case 'read':
|
||||
description = 'read secrets'
|
||||
examples = [
|
||||
`# Specific secret
|
||||
path "${mountPoint}/data/${secretPath || '*'}" {
|
||||
capabilities = ["read"]
|
||||
}`,
|
||||
`# All secrets in mount
|
||||
path "${mountPoint}/data/*" {
|
||||
capabilities = ["read"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
|
||||
case 'write':
|
||||
description = 'create and update secrets'
|
||||
examples = [
|
||||
`# Specific secret
|
||||
path "${mountPoint}/data/${secretPath || '*'}" {
|
||||
capabilities = ["create", "update"]
|
||||
}`,
|
||||
`# All secrets in mount
|
||||
path "${mountPoint}/data/*" {
|
||||
capabilities = ["create", "update"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
description = 'delete secrets'
|
||||
examples = [
|
||||
`# Specific secret
|
||||
path "${mountPoint}/data/${secretPath || '*'}" {
|
||||
capabilities = ["delete"]
|
||||
}`,
|
||||
`# All secrets in mount
|
||||
path "${mountPoint}/data/*" {
|
||||
capabilities = ["delete"]
|
||||
}`,
|
||||
`# For KV v2: also need metadata delete permissions
|
||||
path "${mountPoint}/metadata/${secretPath || '*'}" {
|
||||
capabilities = ["delete"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
|
||||
case 'list':
|
||||
description = 'list secrets'
|
||||
examples = [
|
||||
`# List secrets in mount
|
||||
path "${mountPoint}/metadata/*" {
|
||||
capabilities = ["list"]
|
||||
}`,
|
||||
`# List specific path
|
||||
path "${mountPoint}/metadata/${secretPath || '*'}" {
|
||||
capabilities = ["list"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
combinedCode: '\n' + examples.join('\n\n'),
|
||||
}
|
||||
})
|
||||
|
||||
// Close modal on Escape key
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.isOpen) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
isOpen => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import type { VaultServer, VaultCredentials } from '../types'
|
||||
import { vaultApi, VaultError } from '../services/vaultApi'
|
||||
import { VaultClient, VaultError } from '../services/vaultClient'
|
||||
import { useSweetAlert } from '../composables/useSweetAlert'
|
||||
import { usePolicyModal } from '../composables/usePolicyModal'
|
||||
|
||||
interface Props {
|
||||
server: VaultServer
|
||||
@ -15,15 +17,28 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const secretData = ref<Record<string, unknown> | null>(null)
|
||||
const secretVersion = ref<number>(null)
|
||||
const secretMetadata = ref<any>(null)
|
||||
const secretVersions = ref<any[]>([])
|
||||
const secretVersion = ref<number | null>(null)
|
||||
const secretMetadata = ref<Record<string, unknown> | null>(null)
|
||||
const secretVersions = ref<Array<{ version: number; created_time: string; destroyed: boolean; deletion_time?: string }>>([])
|
||||
const vaultClient = ref<VaultClient | null>(null)
|
||||
|
||||
const { success, error: showError, confirm } = useSweetAlert()
|
||||
const { showPolicyModal } = usePolicyModal()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const activeTab = ref<'current' | 'json' | 'metadata' | 'versions'>('current')
|
||||
const activeTab = ref<'current' | 'json' | 'metadata' | 'versions' | 'edit'>('current')
|
||||
const visibleValues = ref<Record<string, boolean>>({})
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const editedData = ref<Record<string, string>>({})
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize VaultClient
|
||||
vaultClient.value = new VaultClient({
|
||||
server: props.server,
|
||||
credentials: props.credentials,
|
||||
kvVersion: 2, // Default to KV v2, will be auto-detected
|
||||
})
|
||||
loadSecret()
|
||||
})
|
||||
|
||||
@ -37,59 +52,49 @@ const isLatestVersion = computed<boolean>(() => {
|
||||
return secretMetadata.value.current_version == secretVersion.value
|
||||
})
|
||||
|
||||
const loadSecret = async () => {
|
||||
// Generate Vault server UI URL for this secret
|
||||
const vaultServerUrl = computed(() => {
|
||||
const baseUrl = props.server.url.replace(/\/$/, '') // Remove trailing slash
|
||||
// Vault UI uses the path format: /ui/vault/secrets/{mount}/show/{path}
|
||||
const pathParts = props.secretPath.split('/')
|
||||
const mountPoint = pathParts[0]
|
||||
const secretPath = pathParts.slice(1).join('/')
|
||||
|
||||
return `${baseUrl}/ui/vault/secrets/${mountPoint}/show/${secretPath}`
|
||||
})
|
||||
|
||||
const loadSecret = async (version?: number) => {
|
||||
if (!vaultClient.value) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Load current secret data
|
||||
const response = await vaultApi.readSecret(props.server, props.credentials, props.secretPath)
|
||||
// Load secret data using VaultClient (with optional version)
|
||||
const data = await vaultClient.value.read(props.secretPath, version)
|
||||
secretData.value = data
|
||||
|
||||
// For KV v2, the response includes both data and metadata
|
||||
if (response && typeof response === 'object') {
|
||||
// Extract secret data (usually under 'data' key)
|
||||
secretData.value = response.data || response
|
||||
|
||||
// Extract metadata if present in the response
|
||||
if (response.metadata) {
|
||||
secretMetadata.value = {
|
||||
...response.metadata,
|
||||
// Add any additional metadata fields from the response root
|
||||
current_version: response.metadata.version,
|
||||
created_time: response.metadata.created_time,
|
||||
updated_time: response.metadata.created_time, // KV v2 doesn't have separate updated_time in single secret response
|
||||
destroyed: response.metadata.destroyed,
|
||||
deletion_time: response.metadata.deletion_time,
|
||||
custom_metadata: response.metadata.custom_metadata,
|
||||
}
|
||||
|
||||
secretVersion.value = response.metadata.version
|
||||
|
||||
// Create a single version entry from the current metadata
|
||||
if (response.metadata.version) {
|
||||
secretVersions.value = [
|
||||
{
|
||||
version: response.metadata.version,
|
||||
created_time: new Date(response.metadata.created_time).toLocaleString(),
|
||||
destroyed: response.metadata.destroyed,
|
||||
deletion_time: response.metadata.deletion_time,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
if (version) {
|
||||
// If loading a specific version, set it and switch to current tab
|
||||
secretVersion.value = version
|
||||
activeTab.value = 'current'
|
||||
} else {
|
||||
secretData.value = response
|
||||
secretVersion.value = null
|
||||
// If loading current version, also load metadata and version history
|
||||
await loadMetadataAndVersions()
|
||||
}
|
||||
|
||||
// Try to load full metadata and version history from metadata endpoint
|
||||
await loadMetadataAndVersions()
|
||||
} catch (err) {
|
||||
console.error('Error loading secret:', err)
|
||||
if (err instanceof VaultError) {
|
||||
error.value = `${err.message} (HTTP ${err.statusCode || 'Unknown'})`
|
||||
if (err.errors && err.errors.length > 0) {
|
||||
error.value += `\n\nDetails:\n${err.errors.join('\n')}`
|
||||
console.log('🔍 VaultError caught, statusCode:', err.statusCode)
|
||||
if (err.statusCode === 403) {
|
||||
console.log('🔍 403 error detected, showing policy modal')
|
||||
showPolicyModal(props.secretPath, 'read', err.message, 'Permission Denied - Cannot Read Secret')
|
||||
error.value = 'Permission denied. Check the policy guidance modal for details.'
|
||||
} else {
|
||||
error.value = `${err.message} (HTTP ${err.statusCode || 'Unknown'})`
|
||||
if (err.errors && err.errors.length > 0) {
|
||||
error.value += `\n\nDetails:\n${err.errors.join('\n')}`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
@ -100,71 +105,49 @@ const loadSecret = async () => {
|
||||
}
|
||||
|
||||
const loadMetadataAndVersions = async () => {
|
||||
if (!vaultClient.value) return
|
||||
|
||||
try {
|
||||
// Use the dedicated readSecretMetadata method from VaultApi
|
||||
const fullMetadata = await vaultApi.readSecretMetadata(props.server, props.credentials, props.secretPath)
|
||||
// Use VaultClient to read metadata
|
||||
const fullMetadata = await vaultClient.value.readMetadata(props.secretPath)
|
||||
|
||||
if (fullMetadata) {
|
||||
console.log('Full metadata response:', fullMetadata)
|
||||
|
||||
// Merge with existing metadata or replace it
|
||||
secretMetadata.value = {
|
||||
...secretMetadata.value, // Keep any metadata from the secret response
|
||||
...fullMetadata, // Override with full metadata
|
||||
}
|
||||
// Set the metadata
|
||||
secretMetadata.value = fullMetadata
|
||||
|
||||
// Extract complete version history from full metadata
|
||||
if (fullMetadata.versions) {
|
||||
secretVersions.value = Object.entries(fullMetadata.versions)
|
||||
.map(([version, versionData]: [string, any]) => ({
|
||||
.map(([version, versionData]) => ({
|
||||
version: parseInt(version),
|
||||
...versionData,
|
||||
created_time: new Date(versionData.created_time).toLocaleString(),
|
||||
created_time: new Date(versionData.created_time).toLocaleString('eu-CH'),
|
||||
destroyed: versionData.destroyed,
|
||||
deletion_time: versionData.deletion_time,
|
||||
}))
|
||||
.sort((a, b) => b.version - a.version) // Latest first
|
||||
} else if (secretMetadata.value?.current_version) {
|
||||
// Fallback: if no versions array but we have current version info
|
||||
secretVersions.value = [
|
||||
{
|
||||
version: secretMetadata.value.current_version,
|
||||
created_time: secretMetadata.value.created_time ? new Date(secretMetadata.value.created_time).toLocaleString() : 'Unknown',
|
||||
destroyed: secretMetadata.value.destroyed || false,
|
||||
deletion_time: secretMetadata.value.deletion_time,
|
||||
},
|
||||
]
|
||||
|
||||
// Set current version from metadata
|
||||
secretVersion.value = fullMetadata.current_version
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Could not load full metadata (using basic metadata from secret response):', err)
|
||||
// If we can't load full metadata, we'll use what we extracted from the secret response
|
||||
}
|
||||
}
|
||||
|
||||
const loadVersion = async (version: number) => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// For KV v2, append ?version=X to get specific version
|
||||
const versionPath = `${props.secretPath}?version=${version}`
|
||||
const data = await vaultApi.readSecret(props.server, props.credentials, versionPath)
|
||||
secretData.value = data
|
||||
secretVersion.value = version
|
||||
activeTab.value = 'current'
|
||||
} catch (err) {
|
||||
console.error('Error loading version:', err)
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
console.warn('Could not load full metadata (KV v1 or metadata not available):', err)
|
||||
// If we can't load metadata, it might be KV v1 or metadata endpoint not available
|
||||
secretMetadata.value = null
|
||||
secretVersions.value = []
|
||||
secretVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
// Could add a toast notification here
|
||||
success('Copied to clipboard!')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
showError('Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,12 +179,104 @@ const toggleAllValues = () => {
|
||||
visibleValues.value[key] = !hasVisibleValues
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
if (!secretData.value) return
|
||||
|
||||
// Initialize edited data with current secret data (convert all values to strings for editing)
|
||||
editedData.value = {}
|
||||
Object.entries(secretData.value).forEach(([key, value]) => {
|
||||
editedData.value[key] = typeof value === 'string' ? value : JSON.stringify(value)
|
||||
})
|
||||
|
||||
isEditing.value = true
|
||||
activeTab.value = 'edit'
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
isEditing.value = false
|
||||
editedData.value = {}
|
||||
activeTab.value = 'current'
|
||||
}
|
||||
|
||||
const addNewField = () => {
|
||||
const newKey = `new_field_${Date.now()}`
|
||||
editedData.value[newKey] = ''
|
||||
}
|
||||
|
||||
const removeField = (key: string) => {
|
||||
delete editedData.value[key]
|
||||
}
|
||||
|
||||
const saveSecret = async () => {
|
||||
if (!vaultClient.value || !secretData.value) return
|
||||
|
||||
// Validate that we have at least one field
|
||||
if (Object.keys(editedData.value).length === 0) {
|
||||
showError('Cannot save empty secret')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that all keys are non-empty
|
||||
const emptyKeys = Object.keys(editedData.value).filter(key => !key.trim())
|
||||
if (emptyKeys.length > 0) {
|
||||
showError('All field names must be non-empty')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await confirm(
|
||||
'This will create a new version of the secret. The previous version will still be available in the version history.',
|
||||
'Save Secret Changes?'
|
||||
)
|
||||
|
||||
if (!result.isConfirmed) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
// Convert edited data to proper types (try to parse JSON, fallback to string)
|
||||
const dataToSave: Record<string, unknown> = {}
|
||||
Object.entries(editedData.value).forEach(([key, value]) => {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
dataToSave[key] = JSON.parse(value)
|
||||
} catch {
|
||||
// If parsing fails, keep as string
|
||||
dataToSave[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
// Save the secret using VaultClient
|
||||
await vaultClient.value.write(props.secretPath, dataToSave)
|
||||
|
||||
success('Secret saved successfully! New version created.')
|
||||
|
||||
// Reload the secret to show the new version
|
||||
isEditing.value = false
|
||||
editedData.value = {}
|
||||
activeTab.value = 'current'
|
||||
await loadSecret()
|
||||
} catch (err) {
|
||||
console.error('Error saving secret:', err)
|
||||
if (err instanceof VaultError) {
|
||||
if (err.statusCode === 403) {
|
||||
showPolicyModal(props.secretPath, 'write', err.message, 'Permission Denied - Cannot Write Secret')
|
||||
} else {
|
||||
showError(`Failed to save secret: ${err.message}`)
|
||||
}
|
||||
} else {
|
||||
showError('Failed to save secret. Check console for details.')
|
||||
}
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Overlay -->
|
||||
<div class="modal modal-open" @click.self="emit('close')">
|
||||
<div class="modal-box max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div class="modal-box max-w-6xl max-h-[90vh] h-[50vh] overflow-hidden flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start mb-4 flex-shrink-0">
|
||||
<div class="flex-1 min-w-0">
|
||||
@ -272,12 +347,6 @@ const toggleAllValues = () => {
|
||||
<div v-if="activeTab === 'current'" class="h-full flex flex-col">
|
||||
<div class="flex justify-between items-center mb-3 flex-shrink-0">
|
||||
<h3 class="font-semibold">Secret Data</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" @click="toggleAllValues">
|
||||
<i :class="Object.values(visibleValues).some(v => v) ? 'mdi mdi-eye-off' : 'mdi mdi-eye'" class="mr-2" />
|
||||
{{ Object.values(visibleValues).some(v => v) ? 'Hide All' : 'Show All' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="secretData && Object.keys(secretData).length > 0" class="overflow-x-auto">
|
||||
@ -294,8 +363,10 @@ const toggleAllValues = () => {
|
||||
<td class="font-mono font-semibold">
|
||||
{{ key }}
|
||||
</td>
|
||||
<td class="font-mono text-sm">
|
||||
<span class="select-all">{{ getDisplayValue(key, value) }}</span>
|
||||
<td class="font-mono text-sm max-w-0">
|
||||
<div class="overflow-auto max-h-32 whitespace-pre-wrap break-words">
|
||||
<span class="select-all">{{ getDisplayValue(key, value) }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
@ -329,19 +400,23 @@ const toggleAllValues = () => {
|
||||
<div v-else-if="activeTab === 'json'" class="h-full flex flex-col">
|
||||
<div class="flex justify-between items-center mb-3 flex-shrink-0">
|
||||
<h3 class="font-semibold">JSON Data</h3>
|
||||
<button class="btn btn-sm btn-outline" @click="copyToClipboard(JSON.stringify(secretData, null, 2))">
|
||||
<i class="mdi mdi-content-copy mr-2" />
|
||||
Copy JSON
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<pre class="bg-base-300 p-4 rounded-lg text-sm h-full overflow-auto">{{ JSON.stringify(secretData, null, 2) }}</pre>
|
||||
<pre class="bg-base-300 p-4 rounded-lg text-sm overflow-auto whitespace-pre-wrap break-words">{{
|
||||
JSON.stringify(secretData, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Tab -->
|
||||
<div v-else-if="activeTab === 'metadata' && secretMetadata" class="h-full flex flex-col">
|
||||
<h3 class="font-semibold mb-3 flex-shrink-0">Secret Metadata</h3>
|
||||
<div class="flex justify-between items-center mb-3 flex-shrink-0">
|
||||
<h3 class="font-semibold">Secret Metadata</h3>
|
||||
<a :href="vaultServerUrl" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline btn-primary" title="Open in Vault UI">
|
||||
<i class="mdi mdi-open-in-new mr-1"></i>
|
||||
Open in Vault UI
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="card bg-base-200">
|
||||
@ -362,11 +437,11 @@ const toggleAllValues = () => {
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong>
|
||||
{{ secretMetadata.created_time ? new Date(secretMetadata.created_time).toLocaleString() : 'N/A' }}
|
||||
{{ secretMetadata.created_time ? new Date(secretMetadata.created_time).toLocaleString('eu-CH') : 'N/A' }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Updated:</strong>
|
||||
{{ secretMetadata.updated_time ? new Date(secretMetadata.updated_time).toLocaleString() : 'N/A' }}
|
||||
{{ secretMetadata.updated_time ? new Date(secretMetadata.updated_time).toLocaleString('eu-CH') : 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -386,7 +461,9 @@ const toggleAllValues = () => {
|
||||
</div>
|
||||
<div v-if="secretMetadata.custom_metadata">
|
||||
<strong>Custom Metadata:</strong>
|
||||
<pre class="text-xs mt-1 bg-base-300 p-2 rounded">{{ JSON.stringify(secretMetadata.custom_metadata, null, 2) }}</pre>
|
||||
<pre class="text-xs mt-1 bg-base-300 p-2 rounded overflow-auto max-h-32 whitespace-pre-wrap break-words">{{
|
||||
JSON.stringify(secretMetadata.custom_metadata, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -396,7 +473,9 @@ const toggleAllValues = () => {
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="font-semibold text-sm mb-2">Raw Metadata</h4>
|
||||
<pre class="bg-base-300 p-4 rounded text-xs overflow-auto">{{ JSON.stringify(secretMetadata, null, 2) }}</pre>
|
||||
<pre class="bg-base-300 p-4 rounded text-xs overflow-auto whitespace-pre-wrap break-words max-h-60">{{
|
||||
JSON.stringify(secretMetadata, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -418,15 +497,87 @@ const toggleAllValues = () => {
|
||||
<p class="text-sm opacity-70">Created: {{ version.created_time }}</p>
|
||||
<p v-if="version.deletion_time" class="text-sm opacity-70">
|
||||
Deleted:
|
||||
{{ new Date(version.deletion_time).toLocaleString() }}
|
||||
{{ new Date(version.deletion_time).toLocaleString('eu-CH') }}
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="!version.destroyed" class="btn btn-sm btn-primary" @click="loadVersion(version.version)">View Version</button>
|
||||
<button v-if="!version.destroyed" class="btn btn-sm btn-primary" @click="loadSecret(version.version)">View Version</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Tab -->
|
||||
<div v-else-if="activeTab === 'edit'" class="h-full flex flex-col">
|
||||
<div class="flex justify-between items-center mb-3 flex-shrink-0">
|
||||
<h3 class="font-semibold">Edit Secret</h3>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mb-4">
|
||||
<i class="mdi mdi-information" />
|
||||
<div class="text-sm">
|
||||
<p><strong>Editing Secret:</strong> Changes will create a new version of this secret.</p>
|
||||
<p>Previous versions will remain available in the version history.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="space-y-3">
|
||||
<div v-for="(value, key) in editedData" :key="key" class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex gap-3 items-start">
|
||||
<div class="form-control flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Field Name</span>
|
||||
</label>
|
||||
<input
|
||||
:value="key"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="key"
|
||||
@input="
|
||||
e => {
|
||||
const newKey = (e.target as HTMLInputElement).value
|
||||
if (newKey !== key && newKey.trim()) {
|
||||
const value = editedData[key]
|
||||
delete editedData[key]
|
||||
editedData[newKey] = value
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control flex-[2]">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Value</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="editedData[key]"
|
||||
class="textarea textarea-bordered textarea-sm resize-y"
|
||||
rows="1"
|
||||
:placeholder="'Enter value for ' + key"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-error btn-sm mt-8" @click="removeField(key)">
|
||||
<i class="mdi mdi-delete" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 items-start">
|
||||
<button class="btn btn-outline flex-grow" @click="addNewField">
|
||||
<i class="mdi mdi-plus mr-2" />
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="Object.keys(editedData).length === 0" class="text-center py-8 text-base-content/60">
|
||||
<i class="mdi mdi-folder-open text-4xl mb-2" />
|
||||
<p>No fields to edit. Click "Add Field" to create a new field.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -436,6 +587,37 @@ const toggleAllValues = () => {
|
||||
<p><i class="mdi mdi-lock mr-1" />Secret data is never cached - always fetched fresh</p>
|
||||
<p><i class="mdi mdi-chart-line mr-1" />KV v2: Metadata and version history available</p>
|
||||
</div>
|
||||
|
||||
<template v-if="activeTab === 'edit'">
|
||||
<button class="btn btn-success" :disabled="isSaving" @click="saveSecret">
|
||||
<span v-if="isSaving" class="loading loading-spinner loading-xs mr-2" />
|
||||
<i v-else class="mdi mdi-content-save mr-2" />
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="cancelEdit">
|
||||
<i class="mdi mdi-close mr-2" />
|
||||
Cancel
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'current'">
|
||||
<button v-if="isLatestVersion" class="btn btn-primary" @click="startEdit">
|
||||
<i class="mdi mdi-pencil mr-2" />
|
||||
Edit Secret
|
||||
</button>
|
||||
<button class="btn btn-outline" @click="toggleAllValues">
|
||||
<i :class="Object.values(visibleValues).some(v => v) ? 'mdi mdi-eye-off' : 'mdi mdi-eye'" class="mr-2" />
|
||||
{{ Object.values(visibleValues).some(v => v) ? 'Hide All' : 'Show All' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'json'">
|
||||
<button class="btn btn-outline" @click="copyToClipboard(JSON.stringify(secretData, null, 2))">
|
||||
<i class="mdi mdi-content-copy mr-2" />
|
||||
Copy JSON
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button class="btn" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { VaultServer } from '../types'
|
||||
import { useSweetAlert } from '../composables/useSweetAlert'
|
||||
|
||||
interface Props {
|
||||
servers: VaultServer[]
|
||||
@ -14,6 +15,8 @@ const emit = defineEmits<{
|
||||
selectServer: [server: VaultServer]
|
||||
}>()
|
||||
|
||||
const { confirm } = useSweetAlert()
|
||||
|
||||
const showAddForm = ref(false)
|
||||
const newServer = ref({
|
||||
name: '',
|
||||
@ -36,8 +39,9 @@ const handleSubmit = () => {
|
||||
showAddForm.value = false
|
||||
}
|
||||
|
||||
const handleRemove = (serverId: string, serverName: string) => {
|
||||
if (confirm(`Remove server "${serverName}"?`)) {
|
||||
const handleRemove = async (serverId: string, serverName: string) => {
|
||||
const result = await confirm(`Remove server "${serverName}"?`, 'Remove Server')
|
||||
if (result.isConfirmed) {
|
||||
emit('removeServer', serverId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { AppConfig } from '../config'
|
||||
import { loadConfig, saveConfig } from '../config'
|
||||
import { vaultCache } from '../utils/cache'
|
||||
import { useSweetAlert } from '../composables/useSweetAlert'
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
@ -10,6 +11,7 @@ const emit = defineEmits<{
|
||||
|
||||
const config = ref<AppConfig>(loadConfig())
|
||||
const cacheStats = ref(vaultCache.getStats())
|
||||
const { success, confirm } = useSweetAlert()
|
||||
|
||||
let intervalId: number | null = null
|
||||
|
||||
@ -28,15 +30,16 @@ onUnmounted(() => {
|
||||
|
||||
const handleSave = () => {
|
||||
saveConfig(config.value)
|
||||
alert('Settings saved successfully!')
|
||||
success('Settings saved successfully!')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleClearCache = () => {
|
||||
if (confirm('Are you sure you want to clear the cache?')) {
|
||||
const handleClearCache = async () => {
|
||||
const result = await confirm('This will remove all cached data. Are you sure?', 'Clear Cache?')
|
||||
if (result.isConfirmed) {
|
||||
vaultCache.clear()
|
||||
cacheStats.value = vaultCache.getStats()
|
||||
alert('Cache cleared successfully!')
|
||||
success('Cache cleared successfully!')
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +53,7 @@ const formatBytes = (bytes: number): string => {
|
||||
|
||||
const formatDate = (timestamp: number | null): string => {
|
||||
if (!timestamp) return 'N/A'
|
||||
return new Date(timestamp).toLocaleString()
|
||||
return new Date(timestamp).toLocaleString('eu-CH')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -99,16 +102,15 @@ const formatDate = (timestamp: number | null): string => {
|
||||
<span class="label-text">Cache expiration (minutes)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="config.cache.maxAge"
|
||||
v-model.number="config.cache.maxAgeMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
min="0"
|
||||
max="1440"
|
||||
class="input input-bordered w-full"
|
||||
:model-value="Math.round(config.cache.maxAge / 1000 / 60)"
|
||||
@update:model-value="config.cache.maxAge = ($event || 30) * 60 * 1000"
|
||||
placeholder="30 (default)"
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">How long cached entries remain valid</span>
|
||||
<span class="label-text-alt">How long cached entries remain valid (empty = 30 minutes)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
53
src/composables/usePolicyModal.ts
Normal file
53
src/composables/usePolicyModal.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface PolicyModalState {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
path: string
|
||||
operation: 'read' | 'write' | 'delete' | 'list'
|
||||
originalError: string
|
||||
}
|
||||
|
||||
const modalState = ref<PolicyModalState>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
path: '',
|
||||
operation: 'read',
|
||||
originalError: '',
|
||||
})
|
||||
|
||||
export function usePolicyModal() {
|
||||
const showPolicyModal = (
|
||||
path: string,
|
||||
operation: 'read' | 'write' | 'delete' | 'list',
|
||||
originalError: string,
|
||||
title: string = 'Permission Denied'
|
||||
) => {
|
||||
console.log('🔍 showPolicyModal called:', { title, path, operation, originalError })
|
||||
modalState.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
path,
|
||||
operation,
|
||||
originalError,
|
||||
}
|
||||
console.log('🔍 modalState updated:', modalState.value.isOpen)
|
||||
}
|
||||
|
||||
const closePolicyModal = () => {
|
||||
console.log('🔍 Closing policy modal')
|
||||
modalState.value = {
|
||||
isOpen: false,
|
||||
title: '',
|
||||
path: '',
|
||||
operation: 'read',
|
||||
originalError: '',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modalState,
|
||||
showPolicyModal,
|
||||
closePolicyModal,
|
||||
}
|
||||
}
|
||||
87
src/composables/useSweetAlert.ts
Normal file
87
src/composables/useSweetAlert.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import Swal from 'sweetalert2'
|
||||
|
||||
export function useSweetAlert() {
|
||||
// Success notification
|
||||
const success = (message: string, title: string = 'Success!') => {
|
||||
return Swal.fire({
|
||||
icon: 'success',
|
||||
title,
|
||||
text: message,
|
||||
timer: 3000,
|
||||
timerProgressBar: true,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
})
|
||||
}
|
||||
|
||||
// Error notification
|
||||
const error = (message: string, title: string = 'Error!') => {
|
||||
return Swal.fire({
|
||||
icon: 'error',
|
||||
title,
|
||||
text: message,
|
||||
timer: 5000,
|
||||
timerProgressBar: true,
|
||||
showConfirmButton: true,
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
})
|
||||
}
|
||||
|
||||
// Warning notification
|
||||
const warning = (message: string, title: string = 'Warning!') => {
|
||||
return Swal.fire({
|
||||
icon: 'warning',
|
||||
title,
|
||||
text: message,
|
||||
timer: 4000,
|
||||
timerProgressBar: true,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
})
|
||||
}
|
||||
|
||||
// Info notification
|
||||
const info = (message: string, title: string = 'Info') => {
|
||||
return Swal.fire({
|
||||
icon: 'info',
|
||||
title,
|
||||
text: message,
|
||||
timer: 3000,
|
||||
timerProgressBar: true,
|
||||
showConfirmButton: false,
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
})
|
||||
}
|
||||
|
||||
// Confirmation dialog
|
||||
const confirm = (message: string, title: string = 'Are you sure?') => {
|
||||
return Swal.fire({
|
||||
icon: 'question',
|
||||
title,
|
||||
text: message,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes',
|
||||
cancelButtonText: 'No',
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
})
|
||||
}
|
||||
|
||||
// Generic alert (for complex cases)
|
||||
const alert = (options: Parameters<typeof Swal.fire>[0]) => {
|
||||
return Swal.fire(options)
|
||||
}
|
||||
|
||||
return {
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
confirm,
|
||||
alert,
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
export interface AppConfig {
|
||||
cache: {
|
||||
maxSizeMB: number // Maximum cache size in megabytes
|
||||
maxAge: number // Maximum age of cache entries in milliseconds
|
||||
maxAgeMinutes: number // Maximum age of cache entries in minutes (0 = 30 minutes default)
|
||||
enabled: boolean
|
||||
}
|
||||
search: {
|
||||
@ -15,7 +15,7 @@ export interface AppConfig {
|
||||
export const defaultConfig: AppConfig = {
|
||||
cache: {
|
||||
maxSizeMB: 10, // 10 MB default
|
||||
maxAge: 1000 * 60 * 30, // 30 minutes
|
||||
maxAgeMinutes: 0, // 0 = 30 minutes default (empty field)
|
||||
enabled: true,
|
||||
},
|
||||
search: {
|
||||
|
||||
@ -2,6 +2,7 @@ import { VaultServer, VaultCredentials, MountPoint } from '../types'
|
||||
import { vaultCache } from '../utils/cache'
|
||||
import { loadConfig } from '../config'
|
||||
import { VaultClient, VaultError } from './vaultClient'
|
||||
import { generate403PolicyGuidance } from '../utils/vaultPolicyHelper'
|
||||
|
||||
export interface SearchResult {
|
||||
path: string
|
||||
@ -91,6 +92,13 @@ class VaultApiService {
|
||||
if (error.errors) {
|
||||
console.error('Details:', error.errors)
|
||||
}
|
||||
|
||||
// Enhance 403 errors with policy guidance
|
||||
if (error.statusCode === 403) {
|
||||
const enhancedMessage = generate403PolicyGuidance(path, 'read', error.message)
|
||||
throw new VaultError(enhancedMessage, error.statusCode, error.errors)
|
||||
}
|
||||
|
||||
// Re-throw to let the caller handle it
|
||||
throw error
|
||||
} else {
|
||||
@ -117,6 +125,13 @@ class VaultApiService {
|
||||
if (error.errors) {
|
||||
console.error('Details:', error.errors)
|
||||
}
|
||||
|
||||
// Enhance 403 errors with policy guidance
|
||||
if (error.statusCode === 403) {
|
||||
const enhancedMessage = generate403PolicyGuidance(path, 'read', error.message)
|
||||
throw new VaultError(enhancedMessage, error.statusCode, error.errors)
|
||||
}
|
||||
|
||||
// Re-throw to let the caller handle it
|
||||
throw error
|
||||
} else {
|
||||
@ -144,6 +159,13 @@ class VaultApiService {
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
console.error(`Vault error writing ${path}:`, error.message)
|
||||
|
||||
// Enhance 403 errors with policy guidance
|
||||
if (error.statusCode === 403) {
|
||||
const enhancedMessage = generate403PolicyGuidance(path, 'write', error.message)
|
||||
throw new VaultError(enhancedMessage, error.statusCode, error.errors)
|
||||
}
|
||||
|
||||
throw error
|
||||
} else {
|
||||
console.error(`Error writing secret at ${path}:`, error)
|
||||
@ -181,11 +203,35 @@ class VaultApiService {
|
||||
/**
|
||||
* Verify login and get available mount points
|
||||
*/
|
||||
async verifyLoginAndGetMounts(server: VaultServer, credentials: VaultCredentials): Promise<MountPoint[]> {
|
||||
async verifyLoginAndGetMounts(
|
||||
server: VaultServer,
|
||||
credentials: VaultCredentials
|
||||
): Promise<{ mountPoints: MountPoint[]; updatedCredentials: VaultCredentials }> {
|
||||
console.log('⚡ Verifying login and fetching mount points...')
|
||||
|
||||
try {
|
||||
const client = this.createClient(server, credentials)
|
||||
let updatedCredentials = { ...credentials }
|
||||
|
||||
// For userpass and LDAP, we need to authenticate first to get a token
|
||||
if (credentials.authMethod === 'userpass' && credentials.username && credentials.password) {
|
||||
console.log('🔐 Authenticating with userpass...')
|
||||
const token = await client.loginUserpass(credentials.username, credentials.password)
|
||||
console.log('✓ Userpass authentication successful, token obtained')
|
||||
// Update credentials with the obtained token
|
||||
updatedCredentials.token = token
|
||||
} else if (credentials.authMethod === 'ldap' && credentials.username && credentials.password) {
|
||||
console.log('🔐 Authenticating with LDAP...')
|
||||
const token = await client.loginLdap(credentials.username, credentials.password)
|
||||
console.log('✓ LDAP authentication successful, token obtained')
|
||||
// Update credentials with the obtained token
|
||||
updatedCredentials.token = token
|
||||
} else if (credentials.authMethod === 'token' && credentials.token) {
|
||||
console.log('🔐 Using provided token for authentication')
|
||||
} else {
|
||||
throw new VaultError('Invalid credentials: missing required fields for authentication method')
|
||||
}
|
||||
|
||||
const mounts = await client.listMounts()
|
||||
|
||||
console.log('📋 Raw mount points from API:', mounts)
|
||||
@ -211,7 +257,7 @@ class VaultApiService {
|
||||
`✓ Found ${mountPoints.length} KV mount point(s):`,
|
||||
mountPoints.map(m => `${m.path} (v${m.options?.version || '1'})`)
|
||||
)
|
||||
return mountPoints
|
||||
return { mountPoints, updatedCredentials }
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
console.error('✗ Login verification failed:', error.message)
|
||||
@ -230,7 +276,8 @@ class VaultApiService {
|
||||
basePath: string,
|
||||
searchTerm: string,
|
||||
currentDepth: number = 0,
|
||||
mountPoint?: string
|
||||
mountPoint?: string,
|
||||
onProgress?: (pathsSearched: number) => void
|
||||
): Promise<SearchResult[]> {
|
||||
const config = loadConfig()
|
||||
|
||||
@ -246,6 +293,11 @@ class VaultApiService {
|
||||
// List items at current path
|
||||
const items = await this.listSecrets(server, credentials, basePath)
|
||||
|
||||
// Report progress for this path
|
||||
if (onProgress) {
|
||||
onProgress(1)
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = basePath ? `${basePath}${item}` : item
|
||||
const isDirectory = item.endsWith('/')
|
||||
@ -268,7 +320,7 @@ class VaultApiService {
|
||||
|
||||
// If it's a directory, recursively search it
|
||||
if (isDirectory) {
|
||||
const subResults = await this.searchPaths(server, credentials, fullPath, searchTerm, currentDepth + 1, mountPoint)
|
||||
const subResults = await this.searchPaths(server, credentials, fullPath, searchTerm, currentDepth + 1, mountPoint, onProgress)
|
||||
results.push(...subResults)
|
||||
|
||||
// Stop if we've reached max results
|
||||
@ -288,7 +340,13 @@ class VaultApiService {
|
||||
/**
|
||||
* Search across all mount points
|
||||
*/
|
||||
async searchAllMounts(server: VaultServer, credentials: VaultCredentials, mountPoints: MountPoint[], searchTerm: string): Promise<SearchResult[]> {
|
||||
async searchAllMounts(
|
||||
server: VaultServer,
|
||||
credentials: VaultCredentials,
|
||||
mountPoints: MountPoint[],
|
||||
searchTerm: string,
|
||||
onProgress?: (pathsSearched: number) => void
|
||||
): Promise<SearchResult[]> {
|
||||
console.log(`🔍 Searching across ${mountPoints.length} mount point(s)...`)
|
||||
|
||||
const allResults: SearchResult[] = []
|
||||
@ -299,7 +357,7 @@ class VaultApiService {
|
||||
|
||||
try {
|
||||
// Search this mount point (KV v2 enforced)
|
||||
const results = await this.searchPaths(server, credentials, `${mount.path}/`, searchTerm, 0, mount.path)
|
||||
const results = await this.searchPaths(server, credentials, `${mount.path}/`, searchTerm, 0, mount.path, onProgress)
|
||||
|
||||
allResults.push(...results)
|
||||
|
||||
|
||||
@ -25,6 +25,34 @@ export class VaultError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vault authentication response structure
|
||||
*/
|
||||
interface VaultAuthResponse {
|
||||
request_id: string
|
||||
lease_id: string
|
||||
renewable: boolean
|
||||
lease_duration: number
|
||||
data: unknown
|
||||
wrap_info: unknown
|
||||
warnings: unknown
|
||||
auth: {
|
||||
client_token: string
|
||||
accessor: string
|
||||
policies: string[]
|
||||
token_policies: string[]
|
||||
metadata: Record<string, unknown>
|
||||
lease_duration: number
|
||||
renewable: boolean
|
||||
entity_id: string
|
||||
token_type: string
|
||||
orphan: boolean
|
||||
mfa_requirement: unknown
|
||||
num_uses: number
|
||||
}
|
||||
mount_type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-compatible HashiCorp Vault client
|
||||
*
|
||||
@ -84,9 +112,9 @@ export class VaultClient {
|
||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}/v1/${path.replace(/^\//, '')}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
...(options.headers as Record<string, string>),
|
||||
}
|
||||
|
||||
// Add authentication token if available
|
||||
@ -182,18 +210,19 @@ export class VaultClient {
|
||||
* For KV v2, this uses the /data/ endpoint
|
||||
* For KV v1, this uses the path directly
|
||||
*/
|
||||
async read<T = Record<string, unknown>>(path: string): Promise<T | null> {
|
||||
async read<T = Record<string, unknown>>(path: string, version?: number): Promise<T | null> {
|
||||
const normalizedPath = this.transformPath(path, 'data')
|
||||
const pathWithVersion = version ? `${normalizedPath}?version=${version}` : normalizedPath
|
||||
|
||||
if (this.kvVersion === 2) {
|
||||
// KV v2 returns { data: { data: {...}, metadata: {...} } }
|
||||
const response = await this.requestWithRetry<{
|
||||
data: { data: T; metadata?: unknown }
|
||||
}>(normalizedPath, { method: 'GET' })
|
||||
}>(pathWithVersion, { method: 'GET' })
|
||||
return response?.data?.data || null
|
||||
} else {
|
||||
// KV v1 returns { data: {...} }
|
||||
const response = await this.requestWithRetry<{ data: T }>(normalizedPath, { method: 'GET' })
|
||||
const response = await this.requestWithRetry<{ data: T }>(pathWithVersion, { method: 'GET' })
|
||||
return response?.data || null
|
||||
}
|
||||
}
|
||||
@ -290,14 +319,19 @@ export class VaultClient {
|
||||
* Authenticate with username/password
|
||||
*/
|
||||
async loginUserpass(username: string, password: string): Promise<string> {
|
||||
const response = await this.request<{
|
||||
auth: { client_token: string }
|
||||
}>('auth/userpass/login/' + username, {
|
||||
const response = await this.request<VaultAuthResponse>('auth/userpass/login/' + username, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
if (!response.auth || !response.auth.client_token) {
|
||||
throw new VaultError('Authentication failed: no token received from server')
|
||||
}
|
||||
|
||||
this.token = response.auth.client_token
|
||||
console.log('✓ Token extracted from userpass response:', this.token.substring(0, 10) + '...')
|
||||
console.log('✓ User policies:', response.auth.policies)
|
||||
console.log('✓ Token metadata:', response.auth.metadata)
|
||||
return this.token
|
||||
}
|
||||
|
||||
@ -305,14 +339,19 @@ export class VaultClient {
|
||||
* Authenticate with LDAP
|
||||
*/
|
||||
async loginLdap(username: string, password: string): Promise<string> {
|
||||
const response = await this.request<{
|
||||
auth: { client_token: string }
|
||||
}>('auth/ldap/login/' + username, {
|
||||
const response = await this.request<VaultAuthResponse>('auth/ldap/login/' + username, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
if (!response.auth || !response.auth.client_token) {
|
||||
throw new VaultError('Authentication failed: no token received from server')
|
||||
}
|
||||
|
||||
this.token = response.auth.client_token
|
||||
console.log('✓ Token extracted from LDAP response:', this.token.substring(0, 10) + '...')
|
||||
console.log('✓ User policies:', response.auth.policies)
|
||||
console.log('✓ Token metadata:', response.auth.metadata)
|
||||
return this.token
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,12 @@ class VaultCache {
|
||||
this.cache = this.loadFromStorage()
|
||||
}
|
||||
|
||||
private getMaxAgeMs(): number {
|
||||
const config = loadConfig()
|
||||
const minutes = config.cache.maxAgeMinutes || 30 // Default to 30 minutes if 0 or empty
|
||||
return minutes * 60 * 1000 // Convert to milliseconds
|
||||
}
|
||||
|
||||
private loadFromStorage(): Map<string, CacheEntry<unknown>> {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.CACHE_KEY)
|
||||
@ -99,7 +105,7 @@ class VaultCache {
|
||||
|
||||
// Check if entry is expired
|
||||
const age = Date.now() - entry.timestamp
|
||||
if (age > config.cache.maxAge) {
|
||||
if (age > this.getMaxAgeMs()) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
@ -131,7 +137,7 @@ class VaultCache {
|
||||
if (!entry) return false
|
||||
|
||||
const age = Date.now() - entry.timestamp
|
||||
if (age > config.cache.maxAge) {
|
||||
if (age > this.getMaxAgeMs()) {
|
||||
this.cache.delete(key)
|
||||
return false
|
||||
}
|
||||
@ -174,12 +180,12 @@ class VaultCache {
|
||||
|
||||
// Clean up expired entries
|
||||
cleanup(): void {
|
||||
const config = loadConfig()
|
||||
const now = Date.now()
|
||||
const maxAge = this.getMaxAgeMs()
|
||||
const keysToDelete: string[] = []
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now - entry.timestamp > config.cache.maxAge) {
|
||||
if (now - entry.timestamp > maxAge) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
103
src/utils/vaultPolicyHelper.ts
Normal file
103
src/utils/vaultPolicyHelper.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Utility functions for generating Vault policy guidance
|
||||
*/
|
||||
|
||||
export interface PolicyGuidance {
|
||||
operation: 'read' | 'write' | 'delete' | 'list'
|
||||
path: string
|
||||
capabilities: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate policy guidance for common Vault operations
|
||||
*/
|
||||
export function generatePolicyGuidance(path: string, operation: 'read' | 'write' | 'delete' | 'list'): string {
|
||||
const pathParts = path.split('/')
|
||||
const mountPoint = pathParts[0]
|
||||
const secretPath = pathParts.slice(1).join('/')
|
||||
|
||||
let description: string
|
||||
let examples: string[] = []
|
||||
|
||||
switch (operation) {
|
||||
case 'read':
|
||||
description = 'read secrets'
|
||||
examples = [
|
||||
`# Specific secret
|
||||
path "${mountPoint}/data/${secretPath || '*'}" {
|
||||
capabilities = ["read"]
|
||||
}`,
|
||||
`# All secrets in mount
|
||||
path "${mountPoint}/data/*" {
|
||||
capabilities = ["read"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
|
||||
case 'write':
|
||||
description = 'create and update secrets'
|
||||
examples = [
|
||||
`# Specific secret
|
||||
path "${mountPoint}/data/${secretPath || '*'}" {
|
||||
capabilities = ["create", "update"]
|
||||
}`,
|
||||
`# All secrets in mount
|
||||
path "${mountPoint}/data/*" {
|
||||
capabilities = ["create", "update"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
description = 'delete secrets'
|
||||
examples = [
|
||||
`# Specific secret
|
||||
path "${mountPoint}/data/${secretPath || '*'}" {
|
||||
capabilities = ["delete"]
|
||||
}`,
|
||||
`# All secrets in mount
|
||||
path "${mountPoint}/data/*" {
|
||||
capabilities = ["delete"]
|
||||
}`,
|
||||
`# For KV v2: also need metadata delete permissions
|
||||
path "${mountPoint}/metadata/${secretPath || '*'}" {
|
||||
capabilities = ["delete"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
|
||||
case 'list':
|
||||
description = 'list secrets'
|
||||
examples = [
|
||||
`# List secrets in mount
|
||||
path "${mountPoint}/metadata/*" {
|
||||
capabilities = ["list"]
|
||||
}`,
|
||||
`# List specific path
|
||||
path "${mountPoint}/metadata/${secretPath || '*'}" {
|
||||
capabilities = ["list"]
|
||||
}`,
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
return `**Permission Denied (403)**
|
||||
|
||||
You need the following permissions in your Vault policy to ${description}:
|
||||
|
||||
\`\`\`hcl
|
||||
${examples.join('\n\n')}
|
||||
\`\`\`
|
||||
|
||||
**Ask your Vault administrator to add these permissions to your policy.**`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate policy guidance for 403 errors with context
|
||||
*/
|
||||
export function generate403PolicyGuidance(path: string, operation: 'read' | 'write' | 'delete' | 'list', originalError: string): string {
|
||||
const guidance = generatePolicyGuidance(path, operation)
|
||||
return `${guidance}
|
||||
|
||||
**Original error:** ${originalError}`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user