59d4b65195
- Boot: bao-conf.init('sso-manager') replaces conf_manager; deep-merges
secret/sso-manager/conf over file config (fail-soft). Scoped VAULT_TOKEN
(policy sso-broker), never root.
- /api/vault reworked: middleware.auth -> scopeGuard -> token-injecting
proxy. vault_broker.js mints Redis-cached per-user (user-<uid>) /
per-admin (sso-admin) tokens via the sso-broker role; scopeGuard enforces
path prefix on top of the OpenBao policy. Client auth-token stripped.
- vault UI renamed (vaultwarden.ejs -> vault.ejs), /vault route auth-gated,
role-scoped: users see only secret/users/<uid>/, admins get free-form +
Apps mint tab (secret/apps/<name>/*, token shown once).
- api_conf.js writes via bao-conf.set('sso-manager', ...).
- Remediation: config/*-secrets.js untracked+gitignored, test_plugins.js
deleted, proxy-secrets.js.example placeholder added. Secrets remain in
git history; provider-side rotation is the real fix.
Co-Authored-By: Claude <noreply@anthropic.com>
315 lines
14 KiB
Plaintext
315 lines
14 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<div class="container-fluid py-4">
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h2><i class="fas fa-lock"></i>
|
|
<% if (vaultIsAdmin) { %> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>
|
|
<% } else { %> My Secrets <small class="text-muted">(personal namespace)</small><% } %>
|
|
</h2>
|
|
<ul class="nav nav-pills" id="vault-tabs">
|
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
|
<% if (vaultIsAdmin) { %>
|
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
|
<% } %>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="tab-content">
|
|
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
|
<div class="tab-pane fade show active" id="tab-secrets">
|
|
<div class="d-flex justify-content-end mb-3">
|
|
<button class="btn btn-primary" onclick="showCreateModal()">
|
|
<i class="fas fa-plus"></i> New Secret
|
|
</button>
|
|
</div>
|
|
<div class="row">
|
|
<div class="col-md-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
|
|
<div class="list-group list-group-flush" id="secrets-list">
|
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-8">
|
|
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
|
<div>
|
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()"><i class="fas fa-trash"></i> Delete</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
|
|
</div>
|
|
</div>
|
|
<div id="no-secret-selected" class="text-center text-muted mt-5">
|
|
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
|
|
<h4>Select a secret to view its details</h4>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Apps tab (admin only) ───────────────────────────────────────── -->
|
|
<% if (vaultIsAdmin) { %>
|
|
<div class="tab-pane fade" id="tab-apps">
|
|
<div class="row">
|
|
<div class="col-md-5">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
|
<div class="card-body">
|
|
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
|
<div class="mb-3">
|
|
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
|
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
|
</div>
|
|
<button class="btn btn-primary" onclick="mintApp()"><i class="fas fa-key"></i> Mint token</button>
|
|
<div class="alert alert-danger d-none mt-3" id="app-error"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-7">
|
|
<div class="card shadow-sm d-none" id="app-result-card">
|
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
|
<h5 class="card-title mb-0">App token</h5>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="small text-muted">Give the external app this token (header <code>X-Vault-Token</code>) and the path convention below.</p>
|
|
<pre id="app-token" class="bg-dark text-light p-3 rounded"></pre>
|
|
<h6 class="mt-3">Connection convention</h6>
|
|
<pre class="bg-light p-2 rounded small">VAULT_ADDR=<%- vaultAddr %>
|
|
path=secret/apps/<name>/conf
|
|
curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf" \
|
|
-H "X-Vault-Token: <token above>"</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create/Edit Secret Modal -->
|
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="secretModalTitle">Create Secret</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">
|
|
<% if (vaultIsAdmin) { %>Secret path (under secret/)<% } else { %>Secret name (in your personal namespace)<% } %>
|
|
</label>
|
|
<input type="text" class="form-control" id="secret-path-input" placeholder="<% if (vaultIsAdmin) { %>e.g. apps/my-service/conf<% } else { %>e.g. database-creds<% } %>">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Secret Data (JSON)</label>
|
|
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
|
|
"username": "",
|
|
"password": ""
|
|
}</textarea>
|
|
</div>
|
|
<div class="alert alert-danger d-none" id="secret-error"></div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="saveSecret()">Save Secret</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
app.auth.forceLogin();
|
|
|
|
// Server-derived scoping. VAULT_BASE is '' for admins (free-form under
|
|
// secret/) or 'users/<uid>/' for everyone else (confined to their personal
|
|
// namespace). The /api/vault proxy enforces the same server-side; these only
|
|
// drive the UI.
|
|
const VAULT_BASE = <%- JSON.stringify(vaultBase) %>;
|
|
const IS_ADMIN = <%- JSON.stringify(vaultIsAdmin) %>;
|
|
|
|
let currentSecretPath = null;
|
|
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
|
|
// Build a vault API path. kind is 'data' or 'metadata'; key is the logical
|
|
// key relative to the subject's namespace (so 'foo' for a user means
|
|
// secret/data/users/<uid>/foo).
|
|
function vpath(kind, key) {
|
|
return `secret/${kind}/${VAULT_BASE}${key}`;
|
|
}
|
|
|
|
function apiCall(method, path, body = null) {
|
|
const opts = {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() }
|
|
};
|
|
if (body) opts.body = JSON.stringify(body);
|
|
return fetch('/api/vault/' + path, opts).then(async res => {
|
|
if (res.status === 404) return null;
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Vault API error: ${res.status} ${text}`);
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
});
|
|
}
|
|
|
|
async function loadSecrets() {
|
|
try {
|
|
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
|
const listEl = document.getElementById('secrets-list');
|
|
listEl.innerHTML = '';
|
|
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
|
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
|
|
return;
|
|
}
|
|
res.data.keys.forEach(key => {
|
|
// KV list returns dir entries with a trailing slash; admins can still
|
|
// open them by typing the full path in the modal. Skip dirs in the list
|
|
// for non-admins (their namespace is flat).
|
|
if (!IS_ADMIN && key.endsWith('/')) return;
|
|
const item = document.createElement('a');
|
|
item.href = '#';
|
|
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
|
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
|
|
item.onclick = (e) => {
|
|
e.preventDefault();
|
|
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
|
|
item.classList.add('active');
|
|
loadSecretDetails(key);
|
|
};
|
|
listEl.appendChild(item);
|
|
});
|
|
} catch (err) {
|
|
document.getElementById('secrets-list').innerHTML =
|
|
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadSecretDetails(key) {
|
|
try {
|
|
currentSecretPath = key;
|
|
document.getElementById('no-secret-selected').style.display = 'none';
|
|
document.getElementById('secret-details-card').style.display = 'block';
|
|
document.getElementById('secret-title').textContent = key;
|
|
document.getElementById('secret-content').textContent = 'Loading...';
|
|
const res = await apiCall('GET', vpath('data', key));
|
|
if (!res || !res.data || !res.data.data) {
|
|
document.getElementById('secret-content').textContent = 'No data found.';
|
|
} else {
|
|
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
function showCreateModal() {
|
|
currentSecretPath = null;
|
|
document.getElementById('secretModalTitle').textContent = 'Create Secret';
|
|
document.getElementById('secret-path-input').value = '';
|
|
document.getElementById('secret-path-input').disabled = false;
|
|
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
|
|
document.getElementById('secret-error').classList.add('d-none');
|
|
secretModal.show();
|
|
}
|
|
|
|
function editCurrentSecret() {
|
|
if (!currentSecretPath) return;
|
|
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
|
|
document.getElementById('secret-path-input').value = currentSecretPath;
|
|
document.getElementById('secret-path-input').disabled = true;
|
|
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
|
|
document.getElementById('secret-error').classList.add('d-none');
|
|
secretModal.show();
|
|
}
|
|
|
|
async function saveSecret() {
|
|
const errorEl = document.getElementById('secret-error');
|
|
errorEl.classList.add('d-none');
|
|
const path = document.getElementById('secret-path-input').value.trim();
|
|
if (!path) {
|
|
errorEl.textContent = 'Secret path is required';
|
|
errorEl.classList.remove('d-none');
|
|
return;
|
|
}
|
|
let data;
|
|
try {
|
|
data = JSON.parse(document.getElementById('secret-data-input').value);
|
|
} catch (err) {
|
|
errorEl.textContent = 'Invalid JSON: ' + err.message;
|
|
errorEl.classList.remove('d-none');
|
|
return;
|
|
}
|
|
try {
|
|
await apiCall('POST', vpath('data', path), { data });
|
|
secretModal.hide();
|
|
await loadSecrets();
|
|
if (currentSecretPath === path || !currentSecretPath) {
|
|
await loadSecretDetails(path);
|
|
}
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
async function deleteCurrentSecret() {
|
|
if (!currentSecretPath) return;
|
|
try {
|
|
await apiCall('DELETE', vpath('metadata', currentSecretPath));
|
|
currentSecretPath = null;
|
|
document.getElementById('no-secret-selected').style.display = 'block';
|
|
document.getElementById('secret-details-card').style.display = 'none';
|
|
await loadSecrets();
|
|
} catch (err) {
|
|
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
|
|
}
|
|
}
|
|
|
|
// ── Apps tab (admin) ───────────────────────────────────────────────────
|
|
async function mintApp() {
|
|
const errorEl = document.getElementById('app-error');
|
|
errorEl.classList.add('d-none');
|
|
const name = document.getElementById('app-name-input').value.trim();
|
|
if (!name) {
|
|
errorEl.textContent = 'App name is required';
|
|
errorEl.classList.remove('d-none');
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch('/api/vault/apps', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
|
|
body: JSON.stringify({ name })
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`${res.status} ${text}`);
|
|
}
|
|
const result = await res.json();
|
|
document.getElementById('app-token').textContent = result.token;
|
|
document.getElementById('app-name-display').textContent = name;
|
|
document.getElementById('app-result-card').classList.remove('d-none');
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
function copyText(text) {
|
|
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
|
}
|
|
|
|
loadSecrets();
|
|
</script>
|
|
|
|
<%- include('bottom') %> |