21a56dce50
Both view routes did server-side auth via req.user, but this app's auth-token is a header set by client JS (localStorage), not a cookie — so req.user is undefined on a browser navigation. permission.byGroup(undefined,...) throws status 401, and the middleware.auth gate on /vault threw Auth.errors.login() (401) for the same reason. Both routes now render the shell unconditionally (like /users, /directory) and gate client-side. conf.ejs already called app.auth.forceLogin; vault.ejs now derives isAdmin + personal namespace from /api/user/me after forceLogin instead of server-rendering them. /api/conf and /api/vault still enforce app_sso_admin + OpenBao scope server-side — only the view-route gating moved client-side where the session lives. Also removed a dead duplicate /conf route. Co-authored-by: Claude <noreply@anthropic.com>
320 lines
14 KiB
Plaintext
320 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 id="vault-title"><i class="fas fa-lock"></i> 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>
|
|
<li class="nav-item" id="vault-apps-tab" style="display:none"><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; revealed client-side for admins) ─────── -->
|
|
<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" id="secret-path-label">Secret name (in your personal namespace)</label>
|
|
<input type="text" class="form-control" id="secret-path-input" placeholder="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>
|
|
// Login gate + client-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 (scopeGuard + the token's OpenBao policy), so this only
|
|
// drives the UI. Resolved in init() after forceLogin loads the user — the
|
|
// previous version read these server-side from req.user, which is undefined
|
|
// on a browser navigation (auth-token is a client-set header, not a cookie).
|
|
let VAULT_BASE = '';
|
|
let IS_ADMIN = false;
|
|
|
|
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'));
|
|
}
|
|
|
|
(async function init() {
|
|
const user = await app.auth.forceLogin();
|
|
if (!user) return; // not logged in — forceLogin redirected to /login
|
|
IS_ADMIN = app.auth.isAdmin();
|
|
VAULT_BASE = IS_ADMIN ? '' : 'users/' + user.uid + '/';
|
|
if (IS_ADMIN) {
|
|
document.getElementById('vault-apps-tab').style.display = '';
|
|
document.getElementById('vault-title').innerHTML =
|
|
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
|
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
|
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
|
}
|
|
loadSecrets();
|
|
})();
|
|
</script>
|
|
|
|
<%- include('bottom') %> |