Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7b3416619 | |||
| f357c89ac7 | |||
| b9415dcb17 | |||
| 65ba1b16e3 | |||
| 8c4ec67282 | |||
| 36e7dbf8aa | |||
| c56bfe21e5 | |||
| d533a94718 | |||
| fe18393d4e | |||
| e41e7ff9e1 | |||
| 1100872152 | |||
| 16ab12a61c | |||
| 0f6be51c35 | |||
| 463111dfd6 | |||
| 4874544955 | |||
| 256476268f | |||
| a57d579b0e | |||
| 49bf0fa1d3 | |||
| 1b4764e328 |
@@ -1,3 +1,17 @@
|
||||
## v1.18.0
|
||||
- feat: Add SSO-style error page (404/500) for browser navigation instead of a bare text response
|
||||
- feat: navbar — username no longer underlined; only the active link is bold + underlined
|
||||
|
||||
## v1.16.1
|
||||
- fix: remove missing DEPLOYMENT.md from Docker build context
|
||||
|
||||
## v1.16.0
|
||||
- Added OpenBao PKI SSH Certificate Support
|
||||
- Fallback to LDAP Key injection
|
||||
|
||||
# v1.15.0
|
||||
- feat: Rename SSO Manager to Jump in UI
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here. Format loosely
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ COPY nodejs/utils ./utils
|
||||
COPY nodejs/views ./views
|
||||
COPY nodejs/public ./public
|
||||
|
||||
COPY README.md CHANGELOG.md DEPLOYMENT.md /
|
||||
COPY README.md CHANGELOG.md /
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
+11
-1
@@ -4,6 +4,8 @@ const express = require('express');
|
||||
const compression = require('compression');
|
||||
|
||||
require('./models'); // wire model-redis + register models
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('./utils/build_info');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -41,7 +43,15 @@ app.use((err, req, res, next) => {
|
||||
if(req.path.startsWith('/api/')){
|
||||
return res.status(status).json({name: err.name || 'Error', message: err.message || 'Error'});
|
||||
}
|
||||
res.status(status).send(err.message || 'Error');
|
||||
// Browser navigation gets the HTML error page (shared with SSO).
|
||||
res.status(status).render('error', {
|
||||
title: conf.environment !== 'production' ? 'dev' : '',
|
||||
titleIcon: conf.environment !== 'production' ? '<i class="fa-brands fa-dev"></i>' : '',
|
||||
name: conf.name,
|
||||
logo: conf.logo,
|
||||
...buildInfo,
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
// values (LDAP creds, SSO API token) belong in the secrets file.
|
||||
|
||||
module.exports = {
|
||||
name: 'SSO Manager',
|
||||
name: 'Jump',
|
||||
logo: '/static/img/theta42.svg',
|
||||
|
||||
// LDAP directory the users live in (same directory the SSO manages).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.14.0",
|
||||
"version": "1.18.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.14.0",
|
||||
"version": "1.18.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.14.1",
|
||||
"version": "1.18.0",
|
||||
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,12 @@ nav.navbar{
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||
.top-nav a.active{
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -23,7 +23,7 @@ function counter(onBytes) {
|
||||
// Connect the upstream ssh2.Client, retrying once after a short pause if the
|
||||
// first attempt fails auth (SSSD/AuthorizedKeysCommand cache lag right after a
|
||||
// first-time key injection).
|
||||
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
|
||||
function connectUpstream({ host, port, username, privateKey, cert, onHostKey, uid, justInjected, expectedHostKeyFp }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempted = false;
|
||||
const dial = (allowRetry) => {
|
||||
@@ -42,12 +42,16 @@ function connectUpstream({ host, port, username, privateKey, onHostKey, uid, jus
|
||||
})
|
||||
.connect({
|
||||
host, port, username, privateKey,
|
||||
certificates: cert ? [cert] : undefined,
|
||||
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
|
||||
keepaliveInterval: 15000,
|
||||
hostVerifier: (key) => {
|
||||
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
|
||||
if (onHostKey) onHostKey(fp);
|
||||
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
|
||||
if (expectedHostKeyFp && expectedHostKeyFp !== fp) {
|
||||
return false;
|
||||
}
|
||||
return true; // v1: trust-on-use if not pinned, fingerprint audited.
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -129,15 +129,28 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) {
|
||||
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (err) { throw fail('key-inject-failed', err.message, host ? host.slug : undefined); }
|
||||
let cert;
|
||||
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||
|
||||
try {
|
||||
if (usePki) {
|
||||
const { getSignedCert } = require('../utils/vault_cert');
|
||||
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||
} else {
|
||||
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||
}
|
||||
} catch (err) {
|
||||
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||
throw fail(failType, err.message, host ? host.slug : undefined);
|
||||
}
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await connectUpstream({
|
||||
host: endpoint.address, port: endpoint.port,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||
uid: state.uid, justInjected, onHostKey,
|
||||
expectedHostKeyFp: host && host.metadata && host.metadata.sshHostKeyFp,
|
||||
});
|
||||
} catch (err) { throw fail('upstream-unreachable', err.message, host ? host.slug : undefined); }
|
||||
|
||||
@@ -228,15 +241,28 @@ async function runTuiSession(session, client, state) {
|
||||
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
|
||||
|
||||
let justInjected = false;
|
||||
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
|
||||
catch (err) { return finishFail('key-inject-failed', err.message, tui.host.slug); }
|
||||
let cert;
|
||||
const usePki = conf.ssh && conf.ssh.pki && conf.ssh.pki.enabled;
|
||||
|
||||
try {
|
||||
if (usePki) {
|
||||
const { getSignedCert } = require('../utils/vault_cert');
|
||||
cert = await getSignedCert(JUMP_KEYS.publicLine, state.uid);
|
||||
} else {
|
||||
justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine);
|
||||
}
|
||||
} catch (err) {
|
||||
const failType = usePki ? 'pki-cert-failed' : 'key-inject-failed';
|
||||
return finishFail(failType, err.message, tui.host.slug);
|
||||
}
|
||||
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await connectUpstream({
|
||||
host: endpoint.address, port: endpoint.port,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey,
|
||||
username: state.uid, privateKey: JUMP_KEYS.clientKey, cert,
|
||||
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
|
||||
expectedHostKeyFp: tui.host && tui.host.metadata && tui.host.metadata.sshHostKeyFp,
|
||||
});
|
||||
} catch (err) {
|
||||
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
|
||||
|
||||
@@ -8,39 +8,33 @@ function stubLdap(groups) {
|
||||
return { getGroups: async () => groups };
|
||||
}
|
||||
|
||||
function stubFetch(byGroup) {
|
||||
function stubFetch(byUid) {
|
||||
return async (url) => {
|
||||
const cn = decodeURIComponent(url.split('group=')[1]);
|
||||
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
|
||||
const uid = url.split('/access/')[1];
|
||||
return { ok: true, json: async () => ({ results: byUid[uid] || [] }) };
|
||||
};
|
||||
}
|
||||
|
||||
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
|
||||
test('drops non-hosts from access projection', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
|
||||
const fetchImpl = stubFetch({
|
||||
host_web01_access: [
|
||||
alice: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' },
|
||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||
{ id: '9', kind: 'service', slug: 'app_gitea' }, // dropped: not a host
|
||||
],
|
||||
host_db_access: [
|
||||
{ id: '1', kind: 'host', slug: 'host_web01' }, // dupe by id
|
||||
{ id: '2', kind: 'host', slug: 'host_db' },
|
||||
],
|
||||
});
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['host_web01_access', 'host_db_access']) });
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||
});
|
||||
|
||||
test('a failing group query does not sink the rest', async () => {
|
||||
test('a failing access query returns empty list without throwing', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes('bad')) return { ok: false, status: 500 };
|
||||
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
|
||||
};
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['bad_access', 'good_access']) });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['3']);
|
||||
const fetchImpl = async () => ({ ok: false, status: 500 });
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
|
||||
test('caches per uid', async () => {
|
||||
@@ -48,23 +42,19 @@ test('caches per uid', async () => {
|
||||
let calls = 0;
|
||||
const user = { uid: 'cara', dn: 'd' };
|
||||
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
|
||||
const ldap = { getGroups: async () => ['g1'] };
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
await accessibleHosts(user, { fetchImpl, ldap });
|
||||
await accessibleHosts(user, { fetchImpl });
|
||||
await accessibleHosts(user, { fetchImpl });
|
||||
assert.strictEqual(calls, 1);
|
||||
});
|
||||
|
||||
test('accepts pre-resolved groups (web UI/OIDC session) without calling ldap.getGroups', async () => {
|
||||
test('does not depend on user.groups or ldap.getGroups', async () => {
|
||||
clearCache();
|
||||
let ldapCalled = false;
|
||||
const user = { uid: 'erin', groups: ['host_web01_access'] };
|
||||
const user = { uid: 'erin' }; // no dn, no groups
|
||||
const fetchImpl = stubFetch({
|
||||
host_web01_access: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||
erin: [{ id: '5', kind: 'host', slug: 'host_web01' }],
|
||||
});
|
||||
const ldap = { getGroups: async () => { ldapCalled = true; return []; } };
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap });
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['5']);
|
||||
assert.strictEqual(ldapCalled, false);
|
||||
});
|
||||
|
||||
test('allHosts fetches the whole host inventory with no group filter', async () => {
|
||||
@@ -80,16 +70,14 @@ test('allHosts fetches the whole host inventory with no group filter', async ()
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
|
||||
});
|
||||
|
||||
test('a bare-array response (envelope drift) is treated as a failed group, not silently []', async () => {
|
||||
test('a bare-array response (envelope drift) returns empty list', async () => {
|
||||
clearCache();
|
||||
const user = { uid: 'dave', dn: 'd' };
|
||||
// drift shape: a bare array instead of { results: [...] }. The shared client
|
||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue, so a
|
||||
// good group alongside still yields its hosts.
|
||||
const fetchImpl = async (url) => {
|
||||
if (url.includes('drift')) return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||
return { ok: true, json: async () => ({ results: [{ id: '8', kind: 'host' }] }) };
|
||||
// throws DirectoryEnvelopeViolation; access.js must catch + continue.
|
||||
const fetchImpl = async () => {
|
||||
return { ok: true, json: async () => [{ id: '7', kind: 'host' }] };
|
||||
};
|
||||
const hosts = await accessibleHosts(user, { fetchImpl, ldap: stubLdap(['drift_access', 'good_access']) });
|
||||
assert.deepStrictEqual(hosts.map((h) => h.id), ['8']);
|
||||
const hosts = await accessibleHosts(user, { fetchImpl });
|
||||
assert.deepStrictEqual(hosts, []);
|
||||
});
|
||||
|
||||
+23
-32
@@ -17,14 +17,9 @@ if (conf.standalone && conf.standalone.enabled) {
|
||||
|
||||
// Which directory hosts may a user reach, and how do we dial them?
|
||||
//
|
||||
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
|
||||
// /api/discovery/me only answers for the API token's own user, and /graph
|
||||
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
|
||||
// directly) with per-group resource lookups:
|
||||
//
|
||||
// 1. LDAP: groups the user's DN is a member of
|
||||
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
|
||||
// 3. union, keep kind === 'host'
|
||||
// We use the SSO's machine-aware /api/discovery/access/:uid endpoint,
|
||||
// which evaluates the user's groups server-side and returns their complete
|
||||
// access projection in one call.
|
||||
//
|
||||
// Results are cached per-uid for a short TTL — the TUI picker and the
|
||||
// username-grammar path share the cache. Dependency-injected fetch/ldap for
|
||||
@@ -51,37 +46,33 @@ if (conf.standalone && conf.standalone.enabled) {
|
||||
|
||||
// Every host in the inventory, unfiltered — for admins (the web UI's own
|
||||
// account is already gated by requireAdmin before this is ever called).
|
||||
async function allHosts({ fetchImpl = fetch } = {}) {
|
||||
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
||||
return resources.filter(r => r.kind === 'host');
|
||||
function isManagedHost(r) {
|
||||
if (!r || r.kind !== 'host') return false;
|
||||
// If managed attribute is present, require it to be true/truthy
|
||||
if (r.metadata && r.metadata.managed !== undefined) {
|
||||
return r.metadata.managed === true || r.metadata.managed === 'true';
|
||||
}
|
||||
// Default to true for manually created hosts that lack explicit managed metadata
|
||||
return true;
|
||||
}
|
||||
|
||||
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
|
||||
async function allHosts({ fetchImpl = fetch } = {}) {
|
||||
const resources = await directoryClient({ fetchImpl }).getResourcesByGroup(undefined, { kind: 'host' });
|
||||
return resources.filter(isManagedHost);
|
||||
}
|
||||
|
||||
async function accessibleHosts(user, { fetchImpl = fetch } = {}) {
|
||||
const hit = cache.get(user.uid);
|
||||
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
|
||||
|
||||
// The SSH path passes an LDAP user ({dn, uid, ...}) with no .groups, so we
|
||||
// look them up; the web UI already has the session's OIDC groups claim
|
||||
// and passes it directly, skipping a redundant LDAP round-trip.
|
||||
const groups = user.groups || await ldap.getGroups(user.dn);
|
||||
|
||||
const seen = new Map();
|
||||
for (const cn of groups) {
|
||||
let resources;
|
||||
try {
|
||||
resources = await fetchResourcesByGroup(cn, { fetchImpl });
|
||||
} catch (error) {
|
||||
// One bad group must not hide the rest; the SSO being down
|
||||
// surfaces as an empty list + log line, not a crash.
|
||||
console.error(`[access] ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
for (const r of resources) {
|
||||
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
|
||||
}
|
||||
let resources = [];
|
||||
try {
|
||||
resources = await directoryClient({ fetchImpl }).getAccess(user.uid);
|
||||
} catch (error) {
|
||||
console.error(`[access] ${error.message}`);
|
||||
}
|
||||
|
||||
const hosts = [...seen.values()];
|
||||
const hosts = resources.filter(isManagedHost);
|
||||
cache.set(user.uid, { at: Date.now(), hosts });
|
||||
return hosts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
/**
|
||||
* Requests a signed SSH certificate from the SSO Manager's OpenBao/Vault proxy.
|
||||
*
|
||||
* @param {string} publicKey - The jump host's public key (e.g. 'ssh-rsa AAAAB3...')
|
||||
* @param {string} targetUid - The username the cert should be valid for
|
||||
* @returns {Promise<string>} - The signed SSH certificate
|
||||
*/
|
||||
async function getSignedCert(publicKey, targetUid) {
|
||||
const sso = conf.sso || {};
|
||||
const pkiConfig = conf.ssh?.pki || {};
|
||||
|
||||
const vaultRole = pkiConfig.role || 'jump-host-role';
|
||||
const endpoint = `${sso.url}/api/vault/ssh/sign/${vaultRole}`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sso.apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
public_key: publicKey,
|
||||
valid_principals: targetUid
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => '');
|
||||
throw new Error(`Failed to sign SSH cert (status ${response.status}): ${errText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.data || !data.data.signed_key) {
|
||||
throw new Error('Vault response missing signed_key');
|
||||
}
|
||||
|
||||
return data.data.signed_key;
|
||||
}
|
||||
|
||||
module.exports = { getSignedCert };
|
||||
@@ -0,0 +1,25 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 text-center">
|
||||
<div class="mb-4">
|
||||
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||
<p class="text-muted mb-4">
|
||||
<% if (error.status === 404) { %>
|
||||
The page you are looking for doesn't exist or has been moved.
|
||||
<% } else { %>
|
||||
An unexpected error occurred. Please try again later.
|
||||
<% } %>
|
||||
</p>
|
||||
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
@@ -90,7 +90,7 @@
|
||||
<hr />
|
||||
<div class="d-grid">
|
||||
<a href="/api/auth/oidc/start" class="btn btn-outline-primary">
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with SSO
|
||||
<i class="fa-solid fa-id-badge"></i> Log in with Jump
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<% if(ui.profileUrl){ %>
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
|
||||
Reference in New Issue
Block a user