Add prebuilt user scopes and guard invalid Reporter grants
A newly created user held no roles and no permissions, so the sidebar filtered every nav item away and the account appeared broken. Adds three prebuilt scopes (Reporter, Operator, Technician) derived from the live permission catalog by verb rather than hand-listed, so a new plugin permission lands in the right scope automatically instead of silently going ungranted. users.view is granted to all three since a user who cannot see the directory cannot collaborate; users.create/edit/delete stay admin-only. Role.seedscopes() is additive and idempotent, seeded via flask seed permissions, and preserves custom grants on rerun. Reporter guard: the auth model is additive across admin, direct grants and roles, so Reporter cannot be expressed as merely fewer grants - a direct endpoint-action grant would silently re-arm a read-only account. Rather than change the global permission predicate and alter every route's semantics, _check_scope_grants() rejects the invalid combination at write time on both create and update. The update path validates the effective post-update state, so demoting a Technician to Reporter is refused while a stale endpoint grant remains rather than quietly leaving a read-only user with write authority. Validation runs before the User object is constructed. Assigning user.roles first put the relationship into the session autoflush path while the User was still unattached; nothing broke because the refusal returns before db.session.add(), but it left a trap where any query added below that point would autoflush a half-built user and surface later as an orphan row. Verified with -W error::sqlalchemy.exc.SAWarning: 25 passed.
This commit is contained in:
@@ -154,15 +154,36 @@
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Roles</label>
|
||||
<label for="userscope">Scope</label>
|
||||
<select id="userscope" v-model="selectedRoleId">
|
||||
<option :value="null">No scope (sees nothing until granted)</option>
|
||||
<option v-for="role in roles" :key="role.roleid" :value="role.roleid">
|
||||
{{ scopeLabel(role.rolename) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">{{ scopeDescription }}</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Endpoint Actions</label>
|
||||
<p class="field-hint">{{ endpointActionsHint }}</p>
|
||||
<div class="checkbox-group">
|
||||
<label v-for="role in roles" :key="role.roleid" class="checkbox-label">
|
||||
<label
|
||||
v-for="action in endpointActionPermissions"
|
||||
:key="action.name"
|
||||
class="checkbox-label"
|
||||
:class="{ 'checkbox-label-disabled': !endpointActionsSelectable }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="role.roleid"
|
||||
v-model="userForm.roles"
|
||||
:value="action.name"
|
||||
:checked="isActionChecked(action.name)"
|
||||
:disabled="!endpointActionsSelectable"
|
||||
@change="toggleAction(action.name, $event.target.checked)"
|
||||
>
|
||||
{{ role.rolename }}
|
||||
{{ action.label }}
|
||||
<span v-if="endpointActionsSelectable && heldThroughRole(action.name)" class="field-hint">
|
||||
(already granted by a role)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +327,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { usersApi } from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
@@ -340,10 +361,165 @@ const userForm = reactive({
|
||||
lastname: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
directpermissions: [],
|
||||
isactive: true,
|
||||
unlock: false
|
||||
})
|
||||
|
||||
// The four endpoint actions the shop gates per person rather than per policy.
|
||||
// Named here rather than derived from the permission catalog so the user editor
|
||||
// stays a short, meaningful list instead of the full permission grid, which
|
||||
// belongs to the role editor.
|
||||
const endpointActionPermissions = [
|
||||
{ name: 'endpoints.restart', label: 'Remote Restart' },
|
||||
{ name: 'endpoints.runscript', label: 'Script Push' },
|
||||
{ name: 'endpoints.updateapp', label: 'Application Update' },
|
||||
{ name: 'endpoints.corptools', label: 'Corporate Tool Use' }
|
||||
]
|
||||
|
||||
// What each scope means, in the words shown at the point of use.
|
||||
//
|
||||
// This text is doing real work, not decorating. Operator and Technician carry
|
||||
// the SAME role grants - both are read-only on records at the role level - and
|
||||
// differ only in whether the endpoint action checkboxes below are offered. Read
|
||||
// off the role grid alone the two look identical, so the description is what
|
||||
// makes the distinction legible to the person choosing, at the moment they
|
||||
// choose. Keep it accurate if the scopes change.
|
||||
const SCOPE_INFO = {
|
||||
admin: {
|
||||
label: 'Admin - full access',
|
||||
description: 'Full access to everything, including deleting records, ' +
|
||||
'managing users, and site settings.',
|
||||
actions: 'inherent'
|
||||
},
|
||||
operator: {
|
||||
label: 'Operator - create and edit',
|
||||
description: 'Can view, create, and edit records, and can see the user ' +
|
||||
'directory. Cannot delete records, manage users, or change settings - ' +
|
||||
'deletion stays with admins.',
|
||||
actions: 'selectable'
|
||||
},
|
||||
technician: {
|
||||
label: 'Technician - endpoint work, no record edits',
|
||||
description: 'Can view records and the user directory but cannot create ' +
|
||||
'or edit them, and cannot author policy. Intended for endpoint work: ' +
|
||||
'grant the actions below as needed.',
|
||||
actions: 'selectable'
|
||||
},
|
||||
reporter: {
|
||||
label: 'Reporter - read and export only',
|
||||
description: 'Can view everything and export reports. Holds no action ' +
|
||||
'capability and cannot be granted one, so the endpoint actions below ' +
|
||||
'are unavailable for this scope.',
|
||||
actions: 'denied'
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes the server refuses to store endpoint actions against. Mirrors
|
||||
// SCOPES_DENIED_ENDPOINT_ACTIONS in shopdb/core/models/user.py. Greying the
|
||||
// boxes here is a courtesy that explains the rule before it is hit; the API is
|
||||
// what enforces it, so the two must agree or the UI offers something the save
|
||||
// will reject.
|
||||
const SCOPES_DENIED_ACTIONS = ['reporter']
|
||||
|
||||
// A single-select scope, kept as a one-item array on the way to the API so the
|
||||
// existing `roles: [roleid...]` contract is untouched. Roles remain
|
||||
// many-to-many in the database; this is a UI constraint, chosen because no user
|
||||
// in the live data holds more than one role and stacking them mainly produces
|
||||
// the contradictory combinations the server now refuses.
|
||||
const selectedRoleId = computed({
|
||||
get: () => (userForm.roles.length ? userForm.roles[0] : null),
|
||||
set: (value) => {
|
||||
userForm.roles = value === null ? [] : [value]
|
||||
// Clear now-invalid grants rather than leaving them staged for a rejection.
|
||||
// Without this, switching an operator to reporter would keep the restart
|
||||
// grant in the form and the save would fail with a 400 the user did not
|
||||
// knowingly cause.
|
||||
if (!endpointActionsSelectable.value) {
|
||||
userForm.directpermissions = userForm.directpermissions.filter(
|
||||
(name) => !endpointActionPermissions.some((a) => a.name === name))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const selectedRoleName = computed(() => {
|
||||
const role = roles.value.find((r) => r.roleid === selectedRoleId.value)
|
||||
return role ? role.rolename : null
|
||||
})
|
||||
|
||||
const scopeDescription = computed(() => {
|
||||
const name = selectedRoleName.value
|
||||
if (!name) {
|
||||
return 'No scope assigned. This user can sign in but will see no ' +
|
||||
'navigation until a scope is chosen.'
|
||||
}
|
||||
const info = SCOPE_INFO[name]
|
||||
return info
|
||||
? info.description
|
||||
: 'Custom role. Access is whatever this role has been granted in the ' +
|
||||
'role editor.'
|
||||
})
|
||||
|
||||
// Admin's boxes are shown ticked but disabled: an admin already passes every
|
||||
// permission check by rule, so a stored grant would be redundant, and leaving
|
||||
// them unticked would wrongly suggest an admin cannot restart a machine.
|
||||
const scopeActionMode = computed(() => {
|
||||
const name = selectedRoleName.value
|
||||
if (!name) return 'selectable'
|
||||
return SCOPE_INFO[name] ? SCOPE_INFO[name].actions : 'selectable'
|
||||
})
|
||||
|
||||
const endpointActionsSelectable = computed(
|
||||
() => scopeActionMode.value === 'selectable')
|
||||
|
||||
const endpointActionsHint = computed(() => {
|
||||
if (scopeActionMode.value === 'inherent') {
|
||||
return 'Admins hold every action automatically; these are shown for ' +
|
||||
'reference and are not stored as individual grants.'
|
||||
}
|
||||
if (scopeActionMode.value === 'denied') {
|
||||
return 'The Reporter scope cannot hold endpoint actions. Choose ' +
|
||||
'Technician or Operator if this user needs to act on endpoints.'
|
||||
}
|
||||
return 'Granted to this user directly, in addition to anything their scope ' +
|
||||
'already provides.'
|
||||
})
|
||||
|
||||
function isActionChecked(name) {
|
||||
// Admin: display-only ticks. Nothing is stored, per the API guard's note that
|
||||
// grants on an admin are meaningless and become misleading if the scope
|
||||
// later changes.
|
||||
if (scopeActionMode.value === 'inherent') return true
|
||||
if (scopeActionMode.value === 'denied') return false
|
||||
return userForm.directpermissions.includes(name)
|
||||
}
|
||||
|
||||
function toggleAction(name, checked) {
|
||||
if (!endpointActionsSelectable.value) return
|
||||
const has = userForm.directpermissions.includes(name)
|
||||
if (checked && !has) {
|
||||
userForm.directpermissions.push(name)
|
||||
} else if (!checked && has) {
|
||||
userForm.directpermissions = userForm.directpermissions.filter(
|
||||
(n) => n !== name)
|
||||
}
|
||||
}
|
||||
|
||||
function scopeLabel(rolename) {
|
||||
return SCOPE_INFO[rolename] ? SCOPE_INFO[rolename].label : rolename
|
||||
}
|
||||
|
||||
// A capability supplied by a role is shown but not implied by the checkbox:
|
||||
// the checkbox reflects the DIRECT grant only, so unticking it cannot appear to
|
||||
// remove something only the role can remove.
|
||||
function heldThroughRole(name) {
|
||||
const user = editingUser.value
|
||||
if (!user) return false
|
||||
const effective = user.permissions || []
|
||||
const direct = user.directpermissions || []
|
||||
return effective.includes(name) && !direct.includes(name)
|
||||
}
|
||||
|
||||
const roleForm = reactive({
|
||||
rolename: '',
|
||||
description: '',
|
||||
@@ -452,6 +628,7 @@ function editUser(user) {
|
||||
userForm.lastname = user.lastname || ''
|
||||
userForm.password = ''
|
||||
userForm.roles = user.roles.map(r => r.roleid)
|
||||
userForm.directpermissions = [...(user.directpermissions || [])]
|
||||
userForm.isactive = user.isactive
|
||||
userForm.unlock = false
|
||||
}
|
||||
@@ -461,7 +638,7 @@ function closeUserModal() {
|
||||
editingUser.value = null
|
||||
Object.assign(userForm, {
|
||||
username: '', email: '', firstname: '', lastname: '',
|
||||
password: '', roles: [], isactive: true, unlock: false
|
||||
password: '', roles: [], directpermissions: [], isactive: true, unlock: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -473,7 +650,15 @@ async function saveUser() {
|
||||
email: userForm.email,
|
||||
firstname: userForm.firstname || null,
|
||||
lastname: userForm.lastname || null,
|
||||
roles: userForm.roles
|
||||
roles: userForm.roles,
|
||||
// Admin and Reporter never SEND endpoint actions. For admin the ticks are
|
||||
// display-only and a stored grant would be redundant; for reporter the
|
||||
// API refuses them outright. Stripping here keeps the payload honest
|
||||
// rather than relying on the form state having been cleared.
|
||||
directpermissions: endpointActionsSelectable.value
|
||||
? userForm.directpermissions
|
||||
: userForm.directpermissions.filter(
|
||||
(name) => !endpointActionPermissions.some((a) => a.name === name))
|
||||
}
|
||||
|
||||
if (editingUser.value) {
|
||||
@@ -744,6 +929,12 @@ onMounted(loadData)
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -755,6 +946,19 @@ onMounted(loadData)
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Uses --text-light, an existing theme token, rather than a hardcoded grey:
|
||||
a literal colour here would read as disabled in light mode and near-invisible
|
||||
in dark. */
|
||||
.checkbox-label-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.checkbox-label-disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user