Show per-PC credentials across the admin interface
- Endpoints (shared key) page: the key is presented as what PCs use until they enroll, with enrollment progress, a link to Enrollment, rewritten guidance, and rotate/revoke confirmations that say how many PCs are not enrolled yet. - Settings cards describe the server certificate as what PCs actually receive, on IIS or Docker; Enrollment has its own icon (the icon set lacked the one the Server Certificate card already asked for). - Server Certificate page checks the certificate the site presents at its public address and compares its pin with the certificate file, warning when they differ. The host comes from site settings only; the check is a TLS handshake, never an HTTP request. - Fleet list: Credential column and filter (enrolled, pending, re-enroll blocked, shared key, not reporting), computed in the list query. - PC detail: credential state, prefix and dates for fleet readers, state only for others, with admin Revoke (optionally blocking re-enrollment) and Allow re-enrollment. - Dashboard: pending enrollment requests, and PCs blocked from re-enrolling for more than seven days. - Runbook: where to find each of these in the app.
This commit is contained in:
@@ -83,6 +83,25 @@
|
||||
<span class="triage-dot"></span>{{ liveness.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Per-PC credential controls. Admin only: every other reader
|
||||
gets the state in the Endpoint status card, and the server
|
||||
re-checks the role on both actions. -->
|
||||
<div v-if="canManageCredential && credential" class="hero-credential">
|
||||
<span class="hero-detail-label hero-status-label">Credential</span>
|
||||
<span class="credential-state" :class="credential.state">
|
||||
{{ credential.statelabel }}
|
||||
</span>
|
||||
<div class="hero-endpoint-actions">
|
||||
<button v-if="credential.state === 'enrolled'"
|
||||
class="btn btn-danger btn-sm credential-revoke"
|
||||
:disabled="credentialbusy"
|
||||
@click="openRevokeCredential">Revoke credential</button>
|
||||
<button v-if="credential.reenrollblocked"
|
||||
class="btn btn-secondary btn-sm credential-allow"
|
||||
:disabled="credentialbusy"
|
||||
@click="confirmAllowReenroll">Allow re-enrollment</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canCommand" class="hero-endpoint-actions">
|
||||
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
|
||||
:disabled="restarting || refreshing">
|
||||
@@ -1007,6 +1026,39 @@
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="showrevokecredential" title="Revoke endpoint credential" size="medium">
|
||||
<p>
|
||||
The credential for
|
||||
<strong>{{ computer?.assetnumber || computer?.name || 'this PC' }}</strong>
|
||||
stops working immediately. {{ revokeCredentialOutcome }}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="hold-lock-check">
|
||||
<input v-model="revokeblockreenroll" type="checkbox" class="credential-block" />
|
||||
<span>Keep this PC from enrolling again</span>
|
||||
</label>
|
||||
<small class="form-hint">
|
||||
Use before rolling the PC back to an older agent, or for a compromised
|
||||
PC. It stays off per-PC credentials until someone allows re-enrollment
|
||||
here or on the Enrollment page.
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="revokeblockreenroll" class="form-group">
|
||||
<label for="revokereason">Reason (optional)</label>
|
||||
<input id="revokereason" v-model="revokereason" maxlength="64"
|
||||
class="form-control" placeholder="rollback" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showrevokecredential = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-danger" :disabled="credentialbusy"
|
||||
@click="submitRevokeCredential">
|
||||
{{ credentialbusy ? 'Revoking...' : 'Revoke credential' }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1061,6 +1113,9 @@ const agentversion = ref(null)
|
||||
// floor on the server cannot leave this page disagreeing with the API.
|
||||
const agentcurrent = ref(null)
|
||||
const actionminimums = ref({})
|
||||
// Per-PC credential state from the same endpoint read. The server withholds
|
||||
// the prefix and timestamps from readers below endpoints.fleet.
|
||||
const credential = ref(null)
|
||||
|
||||
const installedApps = ref([])
|
||||
const assetPanels = ref(null)
|
||||
@@ -1502,6 +1557,7 @@ async function loadLiveness(assetid) {
|
||||
agentversion.value = state.agentversion || null
|
||||
agentcurrent.value = state.agentcurrent || null
|
||||
actionminimums.value = state.actionminimums || {}
|
||||
credential.value = state.credential || null
|
||||
} catch (error) {
|
||||
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
|
||||
// Neither is a page error; the chip just does not appear.
|
||||
@@ -1509,6 +1565,62 @@ async function loadLiveness(assetid) {
|
||||
agentversion.value = null
|
||||
agentcurrent.value = null
|
||||
actionminimums.value = {}
|
||||
credential.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke and allow-reenroll are require_role('admin') on the server.
|
||||
const canManageCredential = computed(() => authStore.isAdmin)
|
||||
const credentialbusy = ref(false)
|
||||
const showrevokecredential = ref(false)
|
||||
const revokeblockreenroll = ref(false)
|
||||
const revokereason = ref('')
|
||||
|
||||
const revokeCredentialOutcome = computed(() => (revokeblockreenroll.value
|
||||
? 'It keeps reporting on the shared key while that is allowed, and does not enroll again.'
|
||||
: 'Its agent falls back to the shared key and enrolls again on its own, if the shared key is allowed.'))
|
||||
|
||||
function openRevokeCredential() {
|
||||
revokeblockreenroll.value = false
|
||||
revokereason.value = ''
|
||||
showrevokecredential.value = true
|
||||
}
|
||||
|
||||
async function submitRevokeCredential() {
|
||||
if (!computer.value?.assetid) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
credentialbusy.value = true
|
||||
try {
|
||||
await endpointsApi.revokeCredential(computer.value.assetid, {
|
||||
blockreenroll: revokeblockreenroll.value,
|
||||
reason: revokereason.value.trim()
|
||||
})
|
||||
toast.success(`Credential revoked for ${label}`)
|
||||
showrevokecredential.value = false
|
||||
await loadLiveness(computer.value.assetid)
|
||||
await assetPanels.value?.refresh()
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Could not revoke the credential for ${label}`))
|
||||
} finally {
|
||||
credentialbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAllowReenroll() {
|
||||
if (!computer.value?.assetid) return
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Allow ${label} to enroll again?\n\n`
|
||||
+ 'Its agent enrolls on its next attempt if it runs agent 1.20 or later.')) return
|
||||
credentialbusy.value = true
|
||||
try {
|
||||
await endpointsApi.allowReenroll(computer.value.assetid)
|
||||
toast.success(`${label} may enroll again`)
|
||||
await loadLiveness(computer.value.assetid)
|
||||
await assetPanels.value?.refresh()
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, `Could not allow re-enrollment for ${label}`))
|
||||
} finally {
|
||||
credentialbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2393,6 +2505,22 @@ async function confirmRetire() {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hero-credential {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.credential-state {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.credential-state.enrolled { color: var(--success-dark); }
|
||||
.credential-state.pending { color: var(--warning-dark); }
|
||||
.credential-state.reenrollblocked { color: var(--danger); }
|
||||
[data-theme="dark"] .credential-state.enrolled { color: var(--success); }
|
||||
[data-theme="dark"] .credential-state.pending { color: var(--warning); }
|
||||
/* Warning-toned, not error-toned: a hold is a deliberate, audited state, not a
|
||||
fault. It should read as "someone did this on purpose" at a glance. */
|
||||
.policy-holds {
|
||||
@@ -3052,7 +3180,8 @@ async function confirmRetire() {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.hero-status { align-items: flex-start; }
|
||||
.hero-status,
|
||||
.hero-credential { align-items: flex-start; }
|
||||
.hero-endpoint-actions { justify-content: flex-start; }
|
||||
}
|
||||
.copy-btn:hover {
|
||||
|
||||
58
plugins/computers/frontend/views/pcDetailCredential.spec.js
Normal file
58
plugins/computers/frontend/views/pcDetailCredential.spec.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Per-PC credential controls on PC Details.
|
||||
*
|
||||
* Revoke and allow-reenroll are admin-role routes on the server. The page reads
|
||||
* the credential from the same endpoint state call the liveness chip uses, and
|
||||
* offers the two actions to admins only; every other reader gets the state in
|
||||
* the Endpoint status card, where the server has already redacted the details.
|
||||
*
|
||||
* Asserted against the file text, matching pcDetailRunScripts.spec.js.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const source = readFileSync(join(here, 'PCDetail.vue'), 'utf8')
|
||||
// Relative to the staged copy under src/.plugins-staged/, which is what runs.
|
||||
const api = readFileSync(join(here, '..', '..', '..', 'api', 'index.js'), 'utf8')
|
||||
|
||||
describe('PCDetail credential controls', () => {
|
||||
it('gates the controls on the admin role', () => {
|
||||
expect(source).toMatch(/const canManageCredential = computed\(\(\) => authStore\.isAdmin\)/)
|
||||
expect(source).toMatch(/v-if="canManageCredential && credential"/)
|
||||
})
|
||||
|
||||
it('reads the credential from the endpoint state response', () => {
|
||||
const loader = source.slice(source.indexOf('async function loadLiveness'))
|
||||
const body = loader.slice(0, loader.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/credential\.value = state\.credential \|\| null/)
|
||||
})
|
||||
|
||||
it('offers revoke only for an enrolled PC and allow only for a blocked one', () => {
|
||||
expect(source).toMatch(/v-if="credential\.state === 'enrolled'"[\s\S]{0,120}credential-revoke/)
|
||||
expect(source).toMatch(/v-if="credential\.reenrollblocked"[\s\S]{0,120}credential-allow/)
|
||||
})
|
||||
|
||||
it('offers the keep-from-enrolling option and sends it with the reason', () => {
|
||||
expect(source).toMatch(/v-model="revokeblockreenroll"/)
|
||||
expect(source).toMatch(/Keep this PC from enrolling again/)
|
||||
const handler = source.slice(source.indexOf('async function submitRevokeCredential'))
|
||||
const body = handler.slice(0, handler.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/blockreenroll: revokeblockreenroll\.value/)
|
||||
expect(body).toMatch(/reason: revokereason\.value\.trim\(\)/)
|
||||
})
|
||||
|
||||
it('calls the existing enrollment routes', () => {
|
||||
expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/revoke`/)
|
||||
expect(api).toMatch(/`\/endpoints\/enrollment\/assets\/\$\{assetId\}\/allow-reenroll`/)
|
||||
expect(api).toMatch(/if \(opts\.blockreenroll\) \{\s*body\.blockreenroll = true/)
|
||||
})
|
||||
|
||||
it('confirms before allowing re-enrollment', () => {
|
||||
const handler = source.slice(source.indexOf('async function confirmAllowReenroll'))
|
||||
const body = handler.slice(0, handler.indexOf('\n}\n'))
|
||||
expect(body).toMatch(/if \(!confirm\(/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user