gigafibre-fsm/services/targo-hub/lib/auth.js
louispaulb dec8321823 feat(ops): unified staff identity (label + email aliases + tech link + departed flag)
One person = one identity { label, primary_email (SSO login), alias_emails[],
tech_id, active, kind }. Every email + tech ID resolves to it.

Hub: lib/identity.js (NEW, SOURCE UNIQUE) — resolver + secured endpoints
(/identity/map|resolve read; upsert/alias/merge/delete admin-only). server.js
mounts /identity. auth.getDisplayNameByEmail consults the identity label first
(any alias → full name everywhere). Seed: Louis-Paul Bourdon, Louis Morneau.

SPA: composables/useIdentity + api/identity. IssueDetail dedupes assignment by
identity (m'ajouter + search = same person → no double chip), shows full names,
and hides departed people (active:false) from the picker. IdentityManager.vue
(Settings → Identités tab): edit label/aliases/tech link, merge duplicates,
block/reactivate (departed = non-destructive: hidden from pickers, history kept).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:22:48 -04:00

571 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const cfg = require('./config')
const crypto = require('crypto')
const { log, json, parseBody, httpRequest, erpFetch } = require('./helpers')
const { sendEmail } = require('./email')
// Strong-but-readable password: 4 base32 chunks separated by dashes,
// e.g. "X7K2-9NQB-4GHM-3RTW". Avoids look-alike chars (0/O, 1/I/L) so
// the user can copy it from a Slack/SMS without guessing case.
function generateInvitePassword () {
const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // no 0/O/1/I/L
const out = []
const buf = crypto.randomBytes(16)
for (let i = 0; i < 16; i++) out.push(alphabet[buf[i] % alphabet.length])
return `${out.slice(0,4).join('')}-${out.slice(4,8).join('')}-${out.slice(8,12).join('')}-${out.slice(12,16).join('')}`
}
let mysql = null
try { mysql = require('mysql2/promise') } catch (e) { /* optional */ }
const CAP_DEFS = {
Dashboard: { view_dashboard_kpi: 'Voir KPIs (revenus, AR)' },
Clients: { view_clients: 'Voir la liste clients', view_client_detail: 'Voir le détail client', edit_customer: 'Modifier les champs client' },
Finance: { view_invoices: 'Voir les factures', create_invoices: 'Créer/modifier factures', view_payments: 'Voir les paiements', create_payments: 'Créer paiements', edit_subscription_price: 'Modifier prix abonnement' },
Dispatch: { view_all_jobs: 'Voir tous les jobs', view_own_jobs: 'Voir ses propres jobs', assign_jobs: 'Assigner/réassigner', create_jobs: 'Créer des jobs' },
'Équipement': { view_equipment: 'Voir diagnostics équipement', reboot_provision: 'Reboot/provisionner' },
Tickets: { view_all_tickets: 'Voir tous les tickets', view_own_tickets: 'Voir ses tickets', manage_tickets: 'Créer/fermer tickets' },
Communication: { send_sms: 'Envoyer SMS', make_calls: 'Passer des appels' },
'Téléphonie': { manage_telephony: 'Gérer Fonoster/trunks' },
Administration: { view_settings: 'Voir paramètres', manage_settings: 'Modifier paramètres', manage_users: 'Gérer utilisateurs', delete_records: 'Supprimer des enregistrements', manage_permissions: 'Modifier les permissions' },
}
const CAPABILITIES = Object.entries(CAP_DEFS).flatMap(([cat, caps]) => Object.entries(caps).map(([key, label]) => ({ key, label, category: cat })))
const OPS_GROUPS = ['admin', 'sysadmin', 'tech', 'support', 'comptabilite', 'facturation', 'dev']
function akFetch (path, method = 'GET', body = null) {
if (!cfg.AUTHENTIK_TOKEN) throw new Error('AUTHENTIK_TOKEN not configured')
return httpRequest(cfg.AUTHENTIK_URL, '/api/v3' + path, {
method, body, headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 10000,
})
}
let groupCache = null, groupCacheTs = 0
async function fetchGroups () {
if (groupCache && Date.now() - groupCacheTs < 60000) return groupCache
const r = await akFetch('/core/groups/?page_size=100')
if (r.status !== 200) throw new Error('Authentik groups fetch failed: ' + r.status)
groupCache = r.data.results
groupCacheTs = Date.now()
return groupCache
}
function invalidateCache () { groupCache = null; groupCacheTs = 0 }
async function findUserByEmail (email) {
const r = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=5')
if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 }
const user = r.data.results?.find(u => u.email?.toLowerCase() === email.toLowerCase())
return user ? { user } : { error: 'User not found: ' + email, status: 404 }
}
// Cache courriel → NOM COMPLET Authentik (TTL 5 min). Sert au « From » sortant
// personnel : on veut afficher le nom canonique (« Louis-Paul Bourdon ») et non
// un nom dérivé du préfixe courriel (« louis@ » → « Louis »). '' si introuvable.
const nameCache = new Map() // email → { name, ts }
async function getDisplayNameByEmail (email) {
const key = String(email || '').trim().toLowerCase()
if (!key) return ''
// Identité unifiée d'abord : un alias (louispaul@…) résout au LABEL canonique (« Louis-Paul Bourdon »).
try { const lbl = require('./identity').labelForEmail(key); if (lbl) return lbl } catch (e) { /* store absent → Authentik */ }
const hit = nameCache.get(key)
if (hit && Date.now() - hit.ts < 300000) return hit.name
let name = ''
try {
const lookup = await findUserByEmail(key)
if (!lookup.error && lookup.user && lookup.user.name) name = String(lookup.user.name).trim()
} catch { /* Authentik indispo → repli sur dérivation locale côté appelant */ }
nameCache.set(key, { name, ts: Date.now() })
return name
}
// Repli : quand le courriel ne correspond à aucun compte OPS (courriel vide côté whoami, ou différent de
// celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami).
async function findUserByUsername (username) {
const r = await akFetch('/core/users/?search=' + encodeURIComponent(username) + '&page_size=5')
if (r.status !== 200) return { error: 'Authentik user lookup failed', status: 502 }
const user = r.data.results?.find(u => u.username?.toLowerCase() === username.toLowerCase())
return user ? { user } : { error: 'User not found: ' + username, status: 404 }
}
// Résout le profil EMPLOYÉ ERPNext lié à un compte de session (courriel Authentik).
// Lien = Employee.user_id (posé à l'invitation) ; REPLI sur company_email. null si non lié.
const EMPLOYEE_FIELDS = ['name', 'employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id', 'status']
async function resolveEmployeeForEmail (email) {
const e = String(email || '').trim()
if (!e) return null
try {
const erp = require('./erp')
let rows = await erp.list('Employee', { filters: [['user_id', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
return rows[0] || null
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
}
function validatePermissions (body) {
const validKeys = new Set(CAPABILITIES.map(c => c.key))
const result = {}
for (const [key, val] of Object.entries(body)) {
if (validKeys.has(key) && typeof val === 'boolean') result[key] = val
}
return result
}
async function handle (req, res, method, path, url) {
if (!cfg.AUTHENTIK_TOKEN) return json(res, 503, { error: 'Authentik not configured' })
try {
const parts = path.replace('/auth/', '').split('/').filter(Boolean)
const resource = parts[0]
if (resource === 'capabilities' && method === 'GET') {
return json(res, 200, CAPABILITIES)
}
if (resource === 'permissions' && method === 'GET') {
const email = url.searchParams.get('email') || req.headers['x-authentik-email']
const username = url.searchParams.get('username') || req.headers['x-authentik-username']
if (!email && !username) return json(res, 400, { error: 'Missing email or username parameter' })
// Recherche par courriel, puis REPLI par username (courriel absent/non provisionné).
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
if (lookup.error && username) lookup = await findUserByUsername(username)
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
const { user } = lookup
const allGroups = await fetchGroups()
const userGroupPks = new Set(user.groups || [])
const userGroups = allGroups.filter(g => userGroupPks.has(g.pk) && OPS_GROUPS.includes(g.name))
const merged = {}
for (const cap of CAPABILITIES) merged[cap.key] = false
for (const g of userGroups) {
const perms = g.attributes?.ops_permissions || {}
for (const [key, val] of Object.entries(perms)) {
if (val === true) merged[key] = true
}
}
const overrides = user.attributes?.ops_permissions_override || {}
for (const [key, val] of Object.entries(overrides)) {
if (typeof val === 'boolean') merged[key] = val
}
// Profil employé lié (log de la session ↔ Employee) — non bloquant si ERPNext indispo.
const employee = await resolveEmployeeForEmail(user.email)
log(`Auth: session ${user.email} (${user.name}) → employé ${employee ? employee.name : 'non lié'}`)
return json(res, 200, {
email: user.email, username: user.username, name: user.name,
groups: userGroups.map(g => g.name), is_superuser: user.is_superuser || false,
capabilities: merged, overrides, employee,
})
}
if (resource === 'groups' && !parts[1] && method === 'GET') {
const allGroups = await fetchGroups()
const opsGroups = allGroups
.filter(g => OPS_GROUPS.includes(g.name))
.map(g => ({
pk: g.pk, name: g.name, num_users: g.users_obj?.length || 0,
is_superuser: g.is_superuser || false, permissions: g.attributes?.ops_permissions || {},
}))
.sort((a, b) => OPS_GROUPS.indexOf(a.name) - OPS_GROUPS.indexOf(b.name))
return json(res, 200, { groups: opsGroups, capabilities: CAPABILITIES })
}
if (resource === 'groups' && parts[2] === 'permissions' && method === 'PUT') {
const groupName = decodeURIComponent(parts[1])
const allGroups = await fetchGroups()
const group = allGroups.find(g => g.name === groupName)
if (!group) return json(res, 404, { error: 'Group not found: ' + groupName })
const perms = validatePermissions(await parseBody(req))
const attrs = { ...(group.attributes || {}), ops_permissions: perms }
const r = await akFetch('/core/groups/' + group.pk + '/', 'PATCH', { attributes: attrs })
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
invalidateCache()
log(`Auth: updated permissions for group ${groupName}: ${Object.keys(perms).filter(k => perms[k]).length} capabilities enabled`)
return json(res, 200, { ok: true, group: groupName, permissions: perms })
}
if (resource === 'users' && !parts[1] && method === 'GET') {
const page = url.searchParams.get('page') || '1'
const search = url.searchParams.get('search') || ''
const groupFilter = url.searchParams.get('group') || ''
let akPath = `/core/users/?page=${page}&page_size=100&ordering=username`
if (search) akPath += '&search=' + encodeURIComponent(search)
if (groupFilter) {
const allGrps = await fetchGroups()
const grp = allGrps.find(g => g.name === groupFilter)
if (grp) akPath += '&groups_by_pk=' + grp.pk
}
const r = await akFetch(akPath)
if (r.status !== 200) return json(res, 502, { error: 'Authentik users fetch failed' })
const allGroups = await fetchGroups()
const groupMap = new Map(allGroups.map(g => [g.pk, g.name]))
const users = r.data.results
.filter(u => !u.username.startsWith('ak-outpost'))
.map(u => ({
pk: u.pk, username: u.username, email: u.email, name: u.name,
is_active: u.is_active, is_superuser: u.is_superuser,
groups: (u.groups || []).map(pk => groupMap.get(pk)).filter(n => OPS_GROUPS.includes(n)),
overrides: u.attributes?.ops_permissions_override || {},
last_login: u.last_login,
}))
return json(res, 200, { users, total: r.data.pagination?.count || users.length })
}
if (resource === 'users' && parts[2] === 'overrides' && method === 'PUT') {
const email = decodeURIComponent(parts[1])
const lookup = await findUserByEmail(email)
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
const { user } = lookup
const overrides = validatePermissions(await parseBody(req))
const attrs = { ...(user.attributes || {}), ops_permissions_override: overrides }
const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { attributes: attrs })
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
log(`Auth: updated overrides for ${email}: ${JSON.stringify(overrides)}`)
return json(res, 200, { ok: true, email, overrides })
}
// PUT /auth/users/{email}/name — modifier le nom complet Authentik.
// Source unique du nom affiché partout (Users & Permissions, From sortant
// personnel). L'adresse et les groupes ne changent pas.
if (resource === 'users' && parts[2] === 'name' && method === 'PUT') {
const email = decodeURIComponent(parts[1])
const lookup = await findUserByEmail(email)
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
const { user } = lookup
const body = await parseBody(req)
const name = String(body.name || '').trim().replace(/[\r\n]/g, '').slice(0, 120)
if (!name) return json(res, 400, { error: 'name required' })
const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { name })
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
nameCache.delete(email.toLowerCase())
log(`Auth: updated name for ${email}: ${name}`)
return json(res, 200, { ok: true, email, name })
}
if (resource === 'users' && parts[2] === 'groups' && method === 'PUT') {
const email = decodeURIComponent(parts[1])
const lookup = await findUserByEmail(email)
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
const { user } = lookup
const { groups: requestedGroups = [] } = await parseBody(req)
const allGroups = await fetchGroups()
const opsGroupMap = new Map(allGroups.filter(g => OPS_GROUPS.includes(g.name)).map(g => [g.name, g.pk]))
const currentNonOps = (user.groups || []).filter(pk => {
const g = allGroups.find(gr => gr.pk === pk)
return g && !OPS_GROUPS.includes(g.name)
})
const finalGroups = [...currentNonOps, ...requestedGroups.map(n => opsGroupMap.get(n)).filter(Boolean)]
const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { groups: finalGroups })
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
invalidateCache()
log(`Auth: updated groups for ${email}: [${requestedGroups.join(', ')}]`)
return json(res, 200, { ok: true, email, groups: requestedGroups })
}
// POST /auth/users — create a new user in Authentik AND ERPNext.
// Body: { email, full_name, groups? = ['sysadmin'], roles? = ['Employee'] }
// Behaviour:
// 1. Authentik: create the user (random password), assign requested
// OPS_GROUPS, then trigger a recovery-link email so the user picks
// their own password on first login.
// 2. ERPNext: create the matching User record (System User by default,
// with Authentik as the Social Login source) so OAuth2 finds them
// on first SSO login. Sends the standard ERPNext welcome email.
// Returns the merged user record consumable by the Settings UI.
if (resource === 'users' && !parts[1] && method === 'POST') {
const body = await parseBody(req)
const email = (body.email || '').trim().toLowerCase()
const fullName = (body.full_name || '').trim()
const requestedGroups = Array.isArray(body.groups) ? body.groups.filter(g => OPS_GROUPS.includes(g)) : ['sysadmin']
const roles = Array.isArray(body.roles) && body.roles.length ? body.roles : ['Employee']
if (!email || !fullName) return json(res, 400, { error: 'email + full_name required' })
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return json(res, 400, { error: 'invalid email' })
// 1a. Pre-flight: did this user already exist on either side? Don't
// fail outright — the dispatcher might be re-inviting somebody
// after a botched first try; re-send the recovery + re-create
// ERPNext record if missing.
const existing = await akFetch('/core/users/?search=' + encodeURIComponent(email) + '&page_size=5')
let akUser = existing.data?.results?.find(u => u.email?.toLowerCase() === email)
// 1b. Username derivation — Authentik usernames are commonly the
// local part of the email; collisions get a numeric suffix.
const baseUser = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'
let username = baseUser
if (!akUser) {
let suffix = 0
// eslint-disable-next-line no-constant-condition
while (true) {
const probe = await akFetch('/core/users/?username=' + encodeURIComponent(username))
if (!probe.data?.results?.length) break
suffix++; username = baseUser + suffix
if (suffix > 50) return json(res, 500, { error: 'Could not allocate Authentik username' })
}
}
// 1c. Resolve requested group names → Authentik group PKs.
const allGroups = await fetchGroups()
const groupPks = requestedGroups
.map(name => allGroups.find(g => g.name === name)?.pk)
.filter(Boolean)
// 1d. Create OR patch the Authentik user. Always end with the requested
// groups attached; resetting if necessary keeps the call idempotent.
if (!akUser) {
const create = await akFetch('/core/users/', 'POST', {
username, name: fullName, email,
is_active: true, groups: groupPks, attributes: {},
})
if (create.status >= 400) {
log('Authentik user create failed:', create.status, JSON.stringify(create.data))
return json(res, 502, { error: 'Authentik create failed', detail: create.data })
}
akUser = create.data
} else {
const patch = await akFetch('/core/users/' + akUser.pk + '/', 'PATCH', {
name: fullName, is_active: true, groups: groupPks,
})
if (patch.status !== 200) {
log('Authentik user patch failed:', patch.status, JSON.stringify(patch.data))
} else {
akUser = patch.data
}
}
// 1e. Set a temporary password + email it via the hub's own SMTP.
// We tried Authentik's recovery_email/ first but the brand has
// no flow_recovery configured ("No recovery flow set"), and
// Authentik's global SMTP is also unset. Rather than build out
// a full recovery flow via API right now, we generate a
// readable temp password on our side, set it via /set_password/
// and mail it to the user via Mailjet (already wired into the
// hub for ERPNext invoice emails). The user can change it on
// first login. Password is also returned to the admin so they
// can hand it over via Slack/SMS if the email gets stuck.
const tempPassword = generateInvitePassword()
let passwordSet = false
try {
const sp = await akFetch('/core/users/' + akUser.pk + '/set_password/', 'POST', { password: tempPassword })
passwordSet = sp.status >= 200 && sp.status < 300
if (!passwordSet) log('set_password failed:', sp.status, JSON.stringify(sp.data).slice(0, 200))
} catch (e) { log('set_password threw:', e.message) }
let emailSent = false
if (passwordSet) {
try {
await sendEmail({
to: email,
subject: 'Bienvenue chez Gigafibre — votre accès',
html: `<p>Bonjour ${fullName},</p>
<p>Un compte vient d'être créé pour vous. Pour vous connecter, utilisez :</p>
<ul>
<li><b>URL ERPNext</b> : <a href="https://erp.gigafibre.ca/">https://erp.gigafibre.ca/</a></li>
<li><b>Identifiant</b> : ${email}</li>
<li><b>Mot de passe temporaire</b> : <code style="background:#f3f4f6;padding:4px 8px;border-radius:4px;font-size:1.05em">${tempPassword}</code></li>
</ul>
<p>À votre première connexion, changez votre mot de passe via votre profil.</p>
<p>Vous pouvez aussi passer par <a href="https://auth.targo.ca/">auth.targo.ca</a> pour le SSO.</p>
<hr/>
<p style="font-size:0.85em;color:#6b7280">Cet email a été envoyé automatiquement par l'application interne Gigafibre.</p>`,
})
emailSent = true
} catch (e) { log('invite email failed:', e.message) }
}
// 2. Create the matching ERPNext User. Frappe wants `roles` as a child
// table of {role: <Role Name>} rows; the Authentik OAuth2 login
// matches by `email` so this record is what gets logged in to.
let erpResult = null
try {
// Skip if already exists.
const probe = await erpFetch('/api/resource/User/' + encodeURIComponent(email))
if (probe.status === 200) {
erpResult = { ok: true, existing: true, name: email }
} else {
const erpBody = {
email, first_name: fullName.split(' ')[0],
last_name: fullName.split(' ').slice(1).join(' '),
full_name: fullName,
user_type: 'System User', enabled: 1,
send_welcome_email: 1,
roles: roles.map(r => ({ role: r })),
social_logins: [{ provider: 'authentik', userid: email }],
}
const create = await erpFetch('/api/resource/User', { method: 'POST', body: JSON.stringify(erpBody) })
if (create.status >= 400) {
log('ERPNext User create failed:', create.status, JSON.stringify(create.data).slice(0, 400))
erpResult = { ok: false, error: 'ERPNext create failed: ' + create.status }
} else {
erpResult = { ok: true, existing: false, name: create.data?.data?.name || email }
}
}
} catch (e) { log('ERPNext User flow failed:', e.message); erpResult = { ok: false, error: e.message } }
log(`Auth: invited ${email} (groups=[${requestedGroups.join(',')}], erp=${erpResult?.ok ? 'ok' : 'failed'}, email=${emailSent ? 'sent' : 'failed'})`)
return json(res, 200, {
ok: true,
user: {
pk: akUser.pk, username: akUser.username, email: akUser.email, name: akUser.name,
is_active: akUser.is_active, groups: requestedGroups, overrides: {},
},
erpnext: erpResult,
// The temp password is the canonical credential for this invite.
// Returned even when emailSent=true so the admin can verify or
// hand it over manually if the user can't find the email.
temp_password: passwordSet ? tempPassword : null,
password_set: passwordSet,
email_sent: emailSent,
})
}
if (resource === 'sync-legacy' && method === 'POST') {
if (!mysql) return json(res, 503, { error: 'mysql2 not installed' })
const body = await parseBody(req)
const dryRun = body.dry_run !== false // default true = preview only
let conn
try {
conn = await mysql.createConnection({
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER,
password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
})
const [rows] = await conn.execute(
'SELECT email, group_ad, first_name, last_name, status FROM staff WHERE status = 1 AND email != "" AND group_ad != ""'
)
await conn.end()
const allGroups = await fetchGroups()
const opsGroupMap = new Map()
for (const g of allGroups) {
if (OPS_GROUPS.includes(g.name)) opsGroupMap.set(g.name, g)
}
let akUsers = [], page = 1
while (true) {
const r = await akFetch('/core/users/?page=' + page + '&page_size=100&ordering=username')
if (r.status !== 200) break
akUsers = akUsers.concat(r.data.results || [])
if (!r.data.pagination || page >= r.data.pagination.total_pages) break
page++
}
const emailMap = new Map()
for (const u of akUsers) {
if (u.email) emailMap.set(u.email.toLowerCase(), u)
}
const report = { matched: [], not_found: [], already_ok: [], errors: [] }
for (const row of rows) {
const legacyEmail = row.email.toLowerCase().trim()
const targetGroup = row.group_ad.trim()
if (!opsGroupMap.has(targetGroup)) continue
const akUser = emailMap.get(legacyEmail)
if (!akUser) {
report.not_found.push({ email: legacyEmail, legacy_group: targetGroup, name: `${row.first_name} ${row.last_name}` })
continue
}
const group = opsGroupMap.get(targetGroup)
const userGroupPks = new Set(akUser.groups || [])
if (userGroupPks.has(group.pk)) {
report.already_ok.push({ email: legacyEmail, group: targetGroup })
continue
}
const entry = { email: legacyEmail, username: akUser.username, group: targetGroup, user_pk: akUser.pk }
if (!dryRun) {
const newGroups = [...(akUser.groups || []), group.pk]
const r = await akFetch('/core/users/' + akUser.pk + '/', 'PATCH', { groups: newGroups })
if (r.status === 200) {
entry.status = 'synced'
log(`Legacy sync: added ${legacyEmail} to group ${targetGroup}`)
} else {
entry.status = 'error'
entry.error = 'Authentik PATCH failed: ' + r.status
report.errors.push(entry)
continue
}
} else {
entry.status = 'pending'
}
report.matched.push(entry)
}
if (!dryRun) invalidateCache()
return json(res, 200, {
dry_run: dryRun,
summary: {
to_sync: report.matched.length,
already_ok: report.already_ok.length,
not_found: report.not_found.length,
errors: report.errors.length,
},
...report,
})
} catch (e) {
if (conn) try { await conn.end() } catch {}
log('Legacy sync error:', e.message)
return json(res, 502, { error: 'Legacy sync error: ' + e.message })
}
}
return json(res, 404, { error: 'Unknown auth endpoint' })
} catch (e) {
log('Auth error:', e.message)
return json(res, 502, { error: 'Auth error: ' + e.message })
}
}
// Capacités EFFECTIVES d'un utilisateur (ops_permissions des groupes Authentik overrides perso).
// RÉUTILISE les mêmes primitives que GET /auth/permissions (findUserBy*, fetchGroups, CAPABILITIES,
// OPS_GROUPS) sans passer par HTTP. Sert au copilote STAFF (lib/staff-agent.js) pour gater les outils
// d'ÉCRITURE par capacité (assign_jobs, create_jobs, …) — parité avec le usePermissions du front.
// → { configured, found, is_superuser, capabilities:{cap:bool}, groups:[...] }
// configured=false quand Authentik n'est pas branché (dev/aperçu) → l'appelant décide (ne pas bloquer en dev).
async function effectiveCapabilities (email, username) {
if (!cfg.AUTHENTIK_TOKEN) return { configured: false, found: false, is_superuser: false, capabilities: {}, groups: [] }
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
if (lookup.error && username) lookup = await findUserByUsername(username)
if (lookup.error) return { configured: true, found: false, is_superuser: false, capabilities: {}, groups: [] }
const { user } = lookup
const allGroups = await fetchGroups()
const userGroupPks = new Set(user.groups || [])
const userGroups = allGroups.filter(g => userGroupPks.has(g.pk) && OPS_GROUPS.includes(g.name))
const merged = {}
for (const cap of CAPABILITIES) merged[cap.key] = false
for (const g of userGroups) {
const perms = g.attributes?.ops_permissions || {}
for (const [k, v] of Object.entries(perms)) if (v === true) merged[k] = true
}
const overrides = user.attributes?.ops_permissions_override || {}
for (const [k, v] of Object.entries(overrides)) if (typeof v === 'boolean') merged[k] = v
return { configured: true, found: true, is_superuser: user.is_superuser || false, capabilities: merged, groups: userGroups.map(g => g.name) }
}
module.exports = { handle, CAPABILITIES, OPS_GROUPS, getDisplayNameByEmail, effectiveCapabilities }