Feat/sso #3
No files matched your search
@@ -27,6 +27,7 @@ import { backupDue, runBackup } from './services/backup.ts';
|
||||
import { sendCallReports } from './services/callreport.ts';
|
||||
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
|
||||
import { logCallsToContrack } from './contacts/timeline.ts';
|
||||
import { checkDirectory } from './sso/directory.ts';
|
||||
import { checkOidcSessions } from './sso/oidcCheck.ts';
|
||||
import { logEvent } from './services/events.ts';
|
||||
import { deliverDue } from './services/webhooks.ts';
|
||||
@@ -267,8 +268,14 @@ export function createJobs(d: JobDeps) {
|
||||
await checkOidcSessions({ db: d.db, log: d.log, now: now() });
|
||||
};
|
||||
|
||||
/** Directory accounts re-read; a leaver disabled (12 §4.5). */
|
||||
const directoryCheck = async () => {
|
||||
await checkDirectory({ db: d.db, log: d.log });
|
||||
};
|
||||
|
||||
return {
|
||||
oidcCheck,
|
||||
directoryCheck,
|
||||
daily,
|
||||
backup,
|
||||
publicIpWatch,
|
||||
@@ -303,6 +310,7 @@ export function startJobs(d: JobDeps): () => void {
|
||||
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
|
||||
run('contrackLog', j.contrackLog);
|
||||
if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
|
||||
if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
|
||||
run('callTranscripts', async () => {
|
||||
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
|
||||
import { endSessionUrl, oidcError } from '../sso/oidc.ts';
|
||||
import { directorySignIn } from '../sso/directory.ts';
|
||||
import { directorySignIn, directoryWarning } from '../sso/directory.ts';
|
||||
import { ldapAuthenticate } from '../sso/ldap.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
import {
|
||||
@@ -237,6 +237,10 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
entry = await ldapAuthenticate(ldap, login, body.password);
|
||||
} catch (err) {
|
||||
req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
|
||||
await directoryWarning(
|
||||
db,
|
||||
`A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
|
||||
);
|
||||
throw new HttpError(
|
||||
503,
|
||||
'directory_unavailable',
|
||||
|
||||
@@ -51,6 +51,7 @@ declare module 'fastify' {
|
||||
const READ_ONLY = new Set<string>([
|
||||
'system.restartRequired',
|
||||
'system.transportHash',
|
||||
'system.directoryWarning',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
]);
|
||||
@@ -456,6 +457,12 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
|
||||
link: '/admin/settings/backups',
|
||||
});
|
||||
if (s['system.directoryWarning'])
|
||||
items.push({
|
||||
code: 'directory_check',
|
||||
message: s['system.directoryWarning'],
|
||||
link: '/admin/settings/sign-in',
|
||||
});
|
||||
if (!s.smtp)
|
||||
items.push({
|
||||
code: 'smtp_missing',
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
|
||||
// email. Never a new user (owner, 2026-10-25).
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { deleteUserSessions } from '../auth/sessions.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { userIdentities, users } from '../db/schema.ts';
|
||||
import { isLastAdmin } from '../routes/users.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import type { DirectoryEntry } from './ldap.ts';
|
||||
import { getSetting, setSetting } from '../services/settings.ts';
|
||||
import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
|
||||
import { applyRole } from './role.ts';
|
||||
|
||||
type UserRow = typeof users.$inferSelect;
|
||||
@@ -69,3 +72,83 @@ export async function directorySignIn(
|
||||
const [after] = await db.select().from(users).where(eq(users.id, u.id));
|
||||
return after!;
|
||||
}
|
||||
|
||||
/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
|
||||
export const directoryWarning = (db: Db, message: string | null) =>
|
||||
setSetting(db, 'system.directoryWarning', message);
|
||||
|
||||
type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
|
||||
|
||||
/**
|
||||
* One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
|
||||
* account still in it gets its email and role; one who isn't is disabled. Never re-enables.
|
||||
* `lookup` is for tests.
|
||||
*/
|
||||
export async function checkDirectory(d: {
|
||||
db: Db;
|
||||
log: Log;
|
||||
lookup?: (s: LdapSettings, ids: string[]) => Promise<Map<string, DirectoryEntry>>;
|
||||
}): Promise<void> {
|
||||
const { db, log } = d;
|
||||
const s = await getSetting(db, 'auth.ldap');
|
||||
const rows = s?.enabled
|
||||
? await db
|
||||
.select({ u: users, subject: userIdentities.subject })
|
||||
.from(userIdentities)
|
||||
.innerJoin(users, eq(users.id, userIdentities.userId))
|
||||
.where(and(eq(userIdentities.kind, 'ldap'), eq(users.isActive, true)))
|
||||
: [];
|
||||
if (!s || !rows.length) return directoryWarning(db, null);
|
||||
|
||||
let found: Map<string, DirectoryEntry>;
|
||||
try {
|
||||
found = await (d.lookup ?? lookupIds)(
|
||||
s,
|
||||
rows.map((r) => r.subject),
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn?.({ err: (err as Error).message }, 'directory check skipped');
|
||||
return directoryWarning(
|
||||
db,
|
||||
`The directory check couldn't reach the directory (${(err as Error).message}), so leavers aren't being disabled.`,
|
||||
);
|
||||
}
|
||||
const gone = rows.filter((r) => !found.has(r.subject));
|
||||
// A broken filter or base DN is likelier than a mass layoff.
|
||||
if (gone.length >= 3 && gone.length * 2 > rows.length) {
|
||||
log.warn?.({ gone: gone.length, of: rows.length }, 'directory check would disable most users');
|
||||
return directoryWarning(
|
||||
db,
|
||||
`The directory check would disable ${gone.length} of ${rows.length} people, so it disabled nobody. Check the base DN and user filter.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const { u, subject } of rows) {
|
||||
const e = found.get(subject);
|
||||
if (e) {
|
||||
await db
|
||||
.update(userIdentities)
|
||||
.set({ email: e.email })
|
||||
.where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
|
||||
await applyRole(db, log, u.id, e.admin, 'the directory', null);
|
||||
continue;
|
||||
}
|
||||
if (u.role === 'admin' && u.passwordHash && (await isLastAdmin(db, u.id))) {
|
||||
log.warn?.(
|
||||
{ userId: u.id },
|
||||
'kept the last admin with a password, though the directory lost them',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
|
||||
await deleteUserSessions(db, u.id);
|
||||
await audit(db, {
|
||||
action: 'directory_disabled',
|
||||
entityType: 'user',
|
||||
entityId: u.id,
|
||||
summary: `${u.email} is no longer in the directory`,
|
||||
diff: { before: { isActive: true }, after: { isActive: false } },
|
||||
});
|
||||
}
|
||||
await directoryWarning(db, null);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { BerWriter } from 'ldapts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { anyLogin, assertLdap, ldapAuthenticate, withLogin, type LdapSettings } from './ldap.ts';
|
||||
import {
|
||||
anyLogin,
|
||||
assertLdap,
|
||||
byIdFilter,
|
||||
ldapAuthenticate,
|
||||
withLogin,
|
||||
type LdapSettings,
|
||||
} from './ldap.ts';
|
||||
|
||||
describe('LDAP (12 §4)', () => {
|
||||
const f = '(&(objectClass=person)(|(uid={login})(mail={login})))';
|
||||
@@ -17,6 +25,19 @@ describe('LDAP (12 §4)', () => {
|
||||
expect(anyLogin(f)).toBe('(&(objectClass=person)(|(uid=*)(mail=*)))');
|
||||
});
|
||||
|
||||
it('finds a leaver by id, keeping the other conditions of the filter (§4.5)', () => {
|
||||
expect(byIdFilter({ idAttribute: 'entryUUID', userFilter: f }, 'a*b').toString()).toBe(
|
||||
'(&(entryUUID=a\\2ab)(&(objectClass=person)(|(uid=*)(mail=*))))',
|
||||
);
|
||||
// AD: objectGUID is stored as hex and searched as its bytes (§4.4).
|
||||
const guid = byIdFilter({ idAttribute: 'objectGUID', userFilter: '(cn={login})' }, '00ff2a');
|
||||
expect(guid.toString()).toBe('(&(objectGUID=\\00\\ff\\2a)(cn=*))');
|
||||
// …and goes out as those three bytes, not U+00FF in UTF-8.
|
||||
const ber = new BerWriter();
|
||||
guid.write(ber);
|
||||
expect(ber.buffer.includes(Buffer.from([0x04, 0x03, 0x00, 0xff, 0x2a]))).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends a password in clear text to another host', () => {
|
||||
const ok = { startTls: false, userFilter: f, adminFilter: null };
|
||||
expect(() => assertLdap({ ...ok, url: 'ldap://dc1.example.com' })).toThrow(/clear text/);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// LDAP / LDAPS password check (12 §4) on ldapts: search the person with the service account,
|
||||
// bind as them with their password, read whether the admin filter matches them.
|
||||
import {
|
||||
AndFilter,
|
||||
Client,
|
||||
EqualityFilter,
|
||||
FilterParser,
|
||||
InvalidCredentialsError,
|
||||
NoSuchObjectError,
|
||||
@@ -182,6 +184,49 @@ export async function ldapAuthenticate(
|
||||
}
|
||||
}
|
||||
|
||||
/** `(&(<id attribute>=<id>)<userFilter with {login} → *>)`: still there, and still let in (§4.5). */
|
||||
export function byIdFilter(s: Pick<LdapSettings, 'idAttribute' | 'userFilter'>, id: string) {
|
||||
// An object, not a string: ldapts parses "\ff" in a string filter to U+00FF and sends it as
|
||||
// UTF-8 (two bytes), so a binary objectGUID would never match. A Buffer goes out as is.
|
||||
const value = s.idAttribute === 'objectGUID' ? Buffer.from(id, 'hex') : id;
|
||||
return new AndFilter({
|
||||
filters: [
|
||||
new EqualityFilter({ attribute: s.idAttribute, value }),
|
||||
FilterParser.parseString(anyLogin(s.userFilter)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The leaver check (12 §4.5): each id → its entry, when the directory still has it and the user
|
||||
* filter still lets it in. One connection for all. Throws DirectoryUnavailable on any failure,
|
||||
* so a half-done run never reads as "gone".
|
||||
*/
|
||||
export async function lookupIds(
|
||||
s: LdapSettings,
|
||||
ids: string[],
|
||||
): Promise<Map<string, DirectoryEntry>> {
|
||||
const c = await connect(s);
|
||||
try {
|
||||
const found = new Map<string, DirectoryEntry>();
|
||||
for (const id of ids) {
|
||||
const r = await c.search(s.baseDn, {
|
||||
scope: 'sub',
|
||||
filter: byIdFilter(s, id),
|
||||
attributes: attributes(s),
|
||||
explicitBufferAttributes: buffers(s),
|
||||
sizeLimit: 2,
|
||||
});
|
||||
if (r.searchEntries.length === 1) found.set(id, await toEntry(c, s, r.searchEntries[0]!));
|
||||
}
|
||||
return found;
|
||||
} catch (err) {
|
||||
throw unavailable(err);
|
||||
} finally {
|
||||
await c.unbind().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Sign-in → Directory → Test (12 §4.1), step by step so it can say which failed. */
|
||||
export async function testLdap(
|
||||
s: LdapSettings,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S5: the Active Directory preset against a real Samba AD DC: sign-in by sAMAccountName, UPN
|
||||
// and mail, admin through a nested group (LDAP_MATCHING_RULE_IN_CHAIN), the disabled bit,
|
||||
// objectGUID as hex, and directoryCheck disabling a deleted user. Needs AD_LIVE_URL (ldaps://…,
|
||||
// with NODE_EXTRA_CA_CERTS naming its CA) and AD_LIVE_ADMIN_PASS (the Administrator's).
|
||||
// Passed against Samba 4 (diegogslomp/samba-ad-dc), its smb.conf given a throwaway certificate
|
||||
// for 127.0.0.1 (`tls keyfile`, `tls certfile`, `tls cafile`):
|
||||
// docker run -d --privileged -p 127.0.0.1:11636:636 -e REALM=EXAMPLE.COM -e DOMAIN=EXAMPLE \
|
||||
// -e ADMIN_PASS=… -e DNS_FORWARDER=1.1.1.1 -e BIND_NETWORK_INTERFACES=false \
|
||||
// diegogslomp/samba-ad-dc
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Attribute, Client } from 'ldapts';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { LDAP_PRESETS } from '@selfphoneng/shared';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { userIdentities, users } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { H, createTestApp } from './app.ts';
|
||||
|
||||
const URL_ = process.env.AD_LIVE_URL;
|
||||
const ADMIN_PASS = process.env.AD_LIVE_ADMIN_PASS ?? '';
|
||||
const DC = 'DC=example,DC=com';
|
||||
const BASE = `CN=Users,${DC}`;
|
||||
const ADMIN_DN = `CN=Administrator,${BASE}`;
|
||||
|
||||
describe.skipIf(!hasTestDb || !URL_)('Active Directory with a real Samba AD DC (12 S5)', () => {
|
||||
const tag = randomBytes(3).toString('hex');
|
||||
const [ann, bob, dan, cat] = ['ann', 'bob', 'dan', 'cat'].map((n) => `${n}${tag}`) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const team = `CN=team-${tag},${BASE}`;
|
||||
const admins = `CN=pbx-admins-${tag},${BASE}`;
|
||||
const dn = (sam: string) => `CN=${sam},${BASE}`;
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let ad: Client;
|
||||
|
||||
const login = (who: string, password: string) =>
|
||||
t.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
headers: H,
|
||||
payload: { login: who, password },
|
||||
});
|
||||
const row = async (sam: string) =>
|
||||
(
|
||||
await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${sam}@example.com`))
|
||||
)[0]!;
|
||||
|
||||
beforeAll(async () => {
|
||||
ad = new Client({ url: URL_! });
|
||||
await ad.bind(ADMIN_DN, ADMIN_PASS);
|
||||
// userAccountControl 512: a normal account; 514: the same, disabled.
|
||||
for (const [sam, uac] of [
|
||||
[ann, '512'],
|
||||
[bob, '512'],
|
||||
[dan, '514'],
|
||||
[cat, '512'],
|
||||
] as const)
|
||||
await ad.add(dn(sam), [
|
||||
...Object.entries({
|
||||
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
|
||||
sAMAccountName: [sam],
|
||||
userPrincipalName: [`${sam}@example.com`],
|
||||
mail: [`${sam}@example.com`],
|
||||
displayName: [sam],
|
||||
userAccountControl: [uac],
|
||||
}).map(([type, values]) => new Attribute({ type, values })),
|
||||
// AD's password: the quoted string in UTF-16LE, only over an encrypted connection.
|
||||
new Attribute({
|
||||
type: 'unicodePwd',
|
||||
values: [Buffer.from(`"${sam}-Directory-Pw1"`, 'utf16le')],
|
||||
}),
|
||||
]);
|
||||
// ann is in team, team is in pbx-admins: only the nested-group rule finds her admin.
|
||||
await ad.add(team, {
|
||||
objectClass: ['top', 'group'],
|
||||
sAMAccountName: `team-${tag}`,
|
||||
member: dn(ann),
|
||||
});
|
||||
await ad.add(admins, {
|
||||
objectClass: ['top', 'group'],
|
||||
sAMAccountName: `pbx-admins-${tag}`,
|
||||
member: team,
|
||||
});
|
||||
|
||||
t = await createTestApp();
|
||||
for (const sam of [ann, bob, dan, cat])
|
||||
await t.addUser(`${sam}@example.com`, 'local-password-1');
|
||||
await setSetting(t.db, 'auth.ldap', {
|
||||
enabled: true,
|
||||
preset: 'ad',
|
||||
url: URL_!,
|
||||
startTls: false,
|
||||
bindDn: ADMIN_DN,
|
||||
bindPasswordEnc: encrypt(ADMIN_PASS),
|
||||
baseDn: BASE,
|
||||
userFilter: LDAP_PRESETS.ad.userFilter,
|
||||
idAttribute: LDAP_PRESETS.ad.idAttribute,
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: `(memberOf:1.2.840.113556.1.4.1941:=${admins})`,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
for (const d of [admins, team, ...[ann, bob, dan, cat].map(dn)])
|
||||
await ad?.del(d).catch(() => {});
|
||||
await ad?.unbind();
|
||||
});
|
||||
|
||||
it('signs in by sAMAccountName; a nested group makes an admin; objectGUID is stored as hex', async () => {
|
||||
resetLoginLimiter();
|
||||
const r = await login(ann, `${ann}-Directory-Pw1`);
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().user).toMatchObject({ role: 'admin', signIn: { ldap: true } });
|
||||
const [ident] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, (await row(ann)).id));
|
||||
expect(ident!.subject).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('signs in by UPN and by mail; outside the group is a user', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(`${bob}@example.com`, `${bob}-Directory-Pw1`)).json().user.role).toBe(
|
||||
'user',
|
||||
);
|
||||
resetLoginLimiter();
|
||||
expect((await login(`${ann}@example.com`, `${ann}-Directory-Pw1`)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('a disabled account (userAccountControl bit 2) is refused', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(dan, `${dan}-Directory-Pw1`)).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('directoryCheck disables a deleted user and keeps the rest', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(cat, `${cat}-Directory-Pw1`)).statusCode).toBe(200);
|
||||
await ad.del(dn(cat));
|
||||
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
|
||||
expect((await row(cat)).isActive).toBe(false);
|
||||
expect(await row(ann)).toMatchObject({ isActive: true, role: 'admin' });
|
||||
expect((await row(bob)).isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 §4.5: the hourly directoryCheck, against a fake directory lookup.
|
||||
import { eq, ne } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { auditLog, sessions, userIdentities, users } from '../src/db/schema.ts';
|
||||
import { getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import type { DirectoryEntry, LdapSettings } from '../src/sso/ldap.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const log = { info: () => {}, warn: () => {} };
|
||||
const LDAP: LdapSettings = {
|
||||
enabled: true,
|
||||
preset: 'lldap',
|
||||
url: 'ldaps://dir.example.com',
|
||||
startTls: false,
|
||||
bindDn: null,
|
||||
bindPasswordEnc: null,
|
||||
baseDn: 'ou=people,dc=example,dc=com',
|
||||
userFilter: '(uid={login})',
|
||||
idAttribute: 'entryUUID',
|
||||
emailAttribute: 'mail',
|
||||
nameAttribute: 'displayName',
|
||||
adminFilter: '(memberOf=cn=pbx-admins)',
|
||||
};
|
||||
|
||||
describe.skipIf(!hasTestDb)('directoryCheck (12 §4.5)', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let n = 0;
|
||||
|
||||
/** A user here with a directory account `id`. */
|
||||
const member = async (role: 'admin' | 'user' = 'user') => {
|
||||
const u = await t.addUser(`dir${++n}@example.com`, 'local-password-1', role);
|
||||
const dirId = `uuid-${n}`;
|
||||
await t.db.insert(userIdentities).values({ userId: u.id, kind: 'ldap', subject: dirId });
|
||||
return { ...u, dirId };
|
||||
};
|
||||
const entry = (id: string, admin: boolean | null, email = `${id}@dir.example.com`) =>
|
||||
[id, { dn: `uid=${id}`, id, email, name: id, admin }] as [string, DirectoryEntry];
|
||||
const run = (found: [string, DirectoryEntry][] | Error) =>
|
||||
checkDirectory({
|
||||
db: t.db,
|
||||
log,
|
||||
lookup: () =>
|
||||
found instanceof Error ? Promise.reject(found) : Promise.resolve(new Map(found)),
|
||||
});
|
||||
const user = async (id: string) => (await t.db.select().from(users).where(eq(users.id, id)))[0]!;
|
||||
const warning = () => getSetting(t.db, 'system.directoryWarning');
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
beforeEach(async () => {
|
||||
await t.db.delete(userIdentities);
|
||||
await t.db.update(users).set({ role: 'user' }).where(ne(users.email, ADMIN.email));
|
||||
await setSetting(t.db, 'auth.ldap', LDAP);
|
||||
});
|
||||
|
||||
it('updates who is still there, and disables who is gone', async () => {
|
||||
const [stays, promoted, leaves] = [await member(), await member(), await member()];
|
||||
await t.login(leaves.email, 'local-password-1');
|
||||
await setSetting(t.db, 'system.directoryWarning', 'old');
|
||||
await run([entry(stays.dirId, false, 'new@dir.example.com'), entry(promoted.dirId, true)]);
|
||||
|
||||
expect(await user(stays.id)).toMatchObject({ isActive: true, role: 'user' });
|
||||
const [ident] = await t.db
|
||||
.select()
|
||||
.from(userIdentities)
|
||||
.where(eq(userIdentities.userId, stays.id));
|
||||
expect(ident!.email).toBe('new@dir.example.com');
|
||||
expect((await user(promoted.id)).role).toBe('admin');
|
||||
expect((await user(leaves.id)).isActive).toBe(false);
|
||||
expect(await t.db.select().from(sessions).where(eq(sessions.userId, leaves.id))).toEqual([]);
|
||||
const audits = await t.db.select().from(auditLog).where(eq(auditLog.entityId, leaves.id));
|
||||
expect(audits.map((a) => a.action)).toContain('directory_disabled');
|
||||
expect(await warning()).toBeNull();
|
||||
});
|
||||
|
||||
it('changes nothing and warns on the dashboard when the directory is down', async () => {
|
||||
const a = await member();
|
||||
await run(new Error('connect ECONNREFUSED'));
|
||||
expect((await user(a.id)).isActive).toBe(true);
|
||||
expect(await warning()).toMatch(/couldn't reach the directory \(connect ECONNREFUSED\)/);
|
||||
const admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
const r = await t.app.inject({
|
||||
url: '/api/system/warnings',
|
||||
headers: { ...H, cookie: admin },
|
||||
});
|
||||
expect(r.json().items).toContainEqual(
|
||||
expect.objectContaining({ code: 'directory_check', link: '/admin/settings/sign-in' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('disables nobody when most people would go (a broken filter, not a layoff)', async () => {
|
||||
const all = [await member(), await member(), await member(), await member()];
|
||||
await run([entry(all[0]!.dirId, null)]);
|
||||
for (const u of all) expect((await user(u.id)).isActive).toBe(true);
|
||||
expect(await warning()).toMatch(/would disable 3 of 4 people/);
|
||||
});
|
||||
|
||||
it('two of four gone is not "most": they are disabled', async () => {
|
||||
const all = [await member(), await member(), await member(), await member()];
|
||||
await run([entry(all[0]!.dirId, null), entry(all[1]!.dirId, null)]);
|
||||
expect((await user(all[2]!.id)).isActive).toBe(false);
|
||||
expect((await user(all[3]!.id)).isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the last active admin with a password', async () => {
|
||||
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
|
||||
await t.db.insert(userIdentities).values({ userId: me!.id, kind: 'ldap', subject: 'admin-id' });
|
||||
await run([]);
|
||||
expect((await user(me!.id)).isActive).toBe(true);
|
||||
await run([entry('admin-id', false)]);
|
||||
expect((await user(me!.id)).role).toBe('admin');
|
||||
});
|
||||
|
||||
it('does nothing with the directory off', async () => {
|
||||
const a = await member();
|
||||
await setSetting(t.db, 'auth.ldap', { ...LDAP, enabled: false });
|
||||
await setSetting(t.db, 'system.directoryWarning', 'old');
|
||||
await run([]);
|
||||
expect((await user(a.id)).isActive).toBe(true);
|
||||
expect(await warning()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 12 S4 against a real lldap: sign in by uid and by email, connect by email, no user made for a
|
||||
// stranger, admin by memberOf, Test, LDAPS. Needs LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and
|
||||
// LDAP_LIVE_HTTP (its web port, to make the users); LDAP_LIVE_LDAPS_URL to try LDAPS too, with
|
||||
// NODE_EXTRA_CA_CERTS naming the CA of its certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
|
||||
// 12 S4-S5 against a real lldap: sign in by uid and by email, connect by email, no user made for
|
||||
// a stranger, admin by memberOf, Test, LDAPS, directoryCheck disabling a removed user. Needs
|
||||
// LDAP_LIVE_URL (ldap://127.0.0.1:<port>) and LDAP_LIVE_HTTP (its web port, to make the users);
|
||||
// LDAP_LIVE_LDAPS_URL to try LDAPS too, with NODE_EXTRA_CA_CERTS naming the CA of its
|
||||
// certificate. Admin password: LDAP_LIVE_ADMIN_PASS.
|
||||
// Passed against lldap 0.6.3:
|
||||
// docker run -d -p 127.0.0.1:13890:3890 -p 127.0.0.1:16360:6360 -p 127.0.0.1:17170:17170 \
|
||||
// -e LLDAP_JWT_SECRET=… -e LLDAP_KEY_SEED=… -e LLDAP_LDAP_USER_PASS=admin-pass-123 \
|
||||
@@ -16,6 +17,7 @@ import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { userIdentities, users } from '../src/db/schema.ts';
|
||||
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { checkDirectory } from '../src/sso/directory.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
@@ -35,6 +37,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
|
||||
const tag = randomBytes(3).toString('hex');
|
||||
const ann = `ann${tag}`;
|
||||
const bob = `bob${tag}`;
|
||||
const cat = `cat${tag}`;
|
||||
const group = `pbx-admins-${tag}`;
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let jwt: string;
|
||||
@@ -82,6 +85,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
|
||||
for (const [id, name] of [
|
||||
[ann, 'Ann Lee'],
|
||||
[bob, 'Bob Roe'],
|
||||
[cat, 'Cat Poe'],
|
||||
])
|
||||
await gql(
|
||||
`mutation { createUser(user: { id: "${id}", email: "${id}@example.com", displayName: "${name}" }) { id } }`,
|
||||
@@ -92,7 +96,7 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
|
||||
await gql(`mutation { addUserToGroup(userId: "${ann}", groupId: ${g.createGroup.id}) { ok } }`);
|
||||
const c = new Client({ url: URL_! });
|
||||
await c.bind(`uid=admin,ou=people,${BASE}`, ADMIN_PASS);
|
||||
for (const id of [ann, bob])
|
||||
for (const id of [ann, bob, cat])
|
||||
await c.exop(
|
||||
'1.3.6.1.4.1.4203.1.11.1',
|
||||
passwordModify(`uid=${id},ou=people,${BASE}`, `${id}-directory-pw`),
|
||||
@@ -103,12 +107,13 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
await t.addUser(`${ann}@example.com`, 'ann-local-pw-12');
|
||||
await t.addUser(`${cat}@example.com`, 'cat-local-pw-12');
|
||||
await setSetting(t.db, 'auth.ldap', settings());
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
for (const id of [ann, bob])
|
||||
for (const id of [ann, bob, cat])
|
||||
await gql(`mutation { deleteUser(userId: "${id}") { ok } }`).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -193,6 +198,22 @@ describe.skipIf(!hasTestDb || !URL_ || !HTTP)('LDAP with a real lldap (12 S4)',
|
||||
expect(bad.json()).toMatchObject({ ok: false, error: expect.stringContaining('refused') });
|
||||
});
|
||||
|
||||
it('directoryCheck disables someone removed from the directory, and only them (§4.5)', async () => {
|
||||
resetLoginLimiter();
|
||||
expect((await login(cat, `${cat}-directory-pw`)).statusCode).toBe(200); // connects cat
|
||||
await gql(`mutation { deleteUser(userId: "${cat}") { ok } }`);
|
||||
await checkDirectory({ db: t.db, log: { info: () => {}, warn: () => {} } });
|
||||
const active = async (who: string) =>
|
||||
(
|
||||
await t.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, `${who}@example.com`))
|
||||
)[0]!.isActive;
|
||||
expect(await active(cat)).toBe(false);
|
||||
expect(await active(ann)).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(!LDAPS)('LDAPS with a private CA (NODE_EXTRA_CA_CERTS)', async () => {
|
||||
resetLoginLimiter();
|
||||
await setSetting(t.db, 'auth.ldap', settings(LDAPS));
|
||||
|
||||
@@ -1640,6 +1640,16 @@ function DirectoryForm({ s }: { s: SettingsDto }) {
|
||||
in with their directory username or email; the first time, they're matched to their
|
||||
user here by email. Nobody gets a user just for being in the directory.
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Every hour, someone no longer in the directory (or no longer matched by the user filter)
|
||||
is disabled here, password and OpenID sign-in included. It never enables anyone again: do
|
||||
that on Users.
|
||||
</p>
|
||||
{s['system.directoryWarning'] && (
|
||||
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
|
||||
{s['system.directoryWarning']}
|
||||
</p>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -141,6 +141,19 @@ the directory. Their SelfPhoneNG password, if they have one, keeps working too.
|
||||
who that login would sign in as here.
|
||||
8. Tick **Check passwords against the directory** and **Save**.
|
||||
|
||||
If the directory is down, people get "The directory can't be reached" and passwords set in
|
||||
SelfPhoneNG still work. Directories without `memberOf` on people (OpenLDAP without the memberof
|
||||
overlay) can't map groups yet. Tested against lldap 0.6.3.
|
||||
### Leavers
|
||||
|
||||
Every hour, SelfPhoneNG looks up each user who has signed in with the directory. Someone deleted
|
||||
from it, disabled there (Active Directory's disabled bit, FreeIPA's lock) or no longer matched by
|
||||
the user filter (removed from the group you added to it) is **disabled here**: their password and
|
||||
OpenID sign-in stop working too, and they're signed out. The role follows the admin filter in the
|
||||
same run. Nobody is enabled again automatically: do that on **Users**.
|
||||
|
||||
If a run would disable more than half of these people (and at least 3), it disables nobody and
|
||||
the dashboard says so: a wrong base DN or filter is likelier than everyone leaving. The last admin
|
||||
who has a password is never disabled.
|
||||
|
||||
If the directory is down, people get "The directory can't be reached", passwords set in
|
||||
SelfPhoneNG still work, and the dashboard shows a warning until the next hourly check reaches it.
|
||||
Directories without `memberOf` on people (OpenLDAP without the memberof overlay) can't map groups
|
||||
yet. Tested against lldap 0.6.3 and Samba 4 Active Directory (nested groups, the disabled bit).
|
||||
@@ -425,7 +425,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
|
||||
| GET/PATCH | `/api/settings` | whole settings object; PATCH merges known keys only. `smtp` comes back as `{host, port, secure, username, from, hasPassword}` and is written with an optional `password` (empty keeps the stored one). `system.*`, `sms.webhookToken` and `setup.completed` are read-only. References (DIDs, extensions, destinations) are checked, and a transport a trunk uses can't be turned off |
|
||||
| POST | `/api/settings/smtp/test` | `{to}` sends a test email |
|
||||
| POST | `/api/settings/emergency/test` | runs 04 §5.3 steps 2–4 with `TEST` in front (email, ring-out, banner); records nothing and dials nothing (06 §6.5). Returns `{notified}` |
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3). "Trunk down" comes from live state in the browser |
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people). "Trunk down" comes from live state in the browser |
|
||||
| POST | `/api/settings/network/detect-public-ip` | |
|
||||
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
|
||||
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
|
||||
|
||||
+28
-4
@@ -385,7 +385,8 @@ For every active user with a directory account, one connection per run:
|
||||
- Search `baseDn` for `(&(<idAttribute>=<id>)<userFilter with {login} → *>)`. Replacing `{login}`
|
||||
with `*` keeps every other condition of the filter (the group, AD's disabled bit, FreeIPA's lock)
|
||||
and makes the login parts presence tests.
|
||||
- Found → update email, name, and role (`adminFilter`). Gone → the user is disabled
|
||||
- Found → update the identity's email and the role (`adminFilter`); the user's own name and email
|
||||
stay as an admin set them (§8.4). Gone → the user is disabled
|
||||
(`is_active = false`, sessions deleted, as an admin's disable), audit `directory_disabled`
|
||||
"… is no longer in the directory". The directory account is the company's record of the person,
|
||||
so it disables the whole user, password and OpenID account included.
|
||||
@@ -453,7 +454,7 @@ For every active user with a directory account, one connection per run:
|
||||
| S2 | OIDC: settings + Test, start/callback, claims (§3.4), §3.3 (connect, disconnect, email match), `mixed` and `oidc` modes, sign out at the provider, login page and My account, fake provider tests | every §6 OIDC case passes against the fake provider. Done, see §8.2 |
|
||||
| S3 | OIDC session check (§3.5); Keycloak live test; admin guide page `docs/admin-guide/sign-in.md` with the §3.6 table | the owner connects their Keycloak account, signs in with it or their password, a Keycloak group makes them admin, signing out also signs out of Keycloak, and disabling them in Keycloak ends their Keycloak session here within 20 min. Done (against a throwaway Keycloak 26.8.1), see §8.3; the owner's own Keycloak still to try |
|
||||
| S4 | LDAP: settings + Test, presets, sign-in flow (§4.2), §4.3, unit + lldap live tests | sign-in by uid and email against lldap over LDAPS; admin by group; no user made for a stranger. Done, see §8.4 |
|
||||
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works |
|
||||
| S5 | `directoryCheck` (§4.5), dashboard warnings; Samba AD check | a user removed from the directory is disabled within an hour; AD nested-group admin works. Done, see §8.5 |
|
||||
|
||||
### 8.1 As built: S1
|
||||
|
||||
@@ -526,11 +527,34 @@ For every active user with a directory account, one connection per run:
|
||||
- `apps/api/src/sso/ldap.test.ts`, `apps/api/test/ldap-live.test.ts` (`LDAP_LIVE_URL`,
|
||||
`LDAP_LIVE_HTTP`, `LDAP_LIVE_LDAPS_URL`; users made through lldap's GraphQL, passwords with an
|
||||
RFC 3062 password modify). Passed against lldap 0.6.3 on 2026-10-25, LDAPS with a throwaway CA
|
||||
through `NODE_EXTRA_CA_CERTS`. The AD, FreeIPA and OpenLDAP presets are not checked yet (S5: a
|
||||
Samba AD DC).
|
||||
through `NODE_EXTRA_CA_CERTS`. The AD, FreeIPA and OpenLDAP presets were not checked in S4 (AD:
|
||||
§8.5).
|
||||
- Web: Settings → Sign-in → Directory (presets, Test with an optional test login), "Email or
|
||||
username" on the login page, the directory account on My settings and the Users drawer.
|
||||
|
||||
### 8.5 As built: S5
|
||||
|
||||
- `checkDirectory` in `sso/directory.ts` (job `directoryCheck`, hourly, `jobs.ts`), `lookupIds` in
|
||||
`sso/ldap.ts`: one connection, one search per id. Any failure in the run (connect, bind, a
|
||||
wrong base DN's `noSuchObject`) changes nothing, so a half-done run never reads as "gone".
|
||||
- **The by-id filter is a filter object, not a string.** ldapts parses `f` in a string filter
|
||||
to U+00FF and sends it as UTF-8 (two bytes), so AD's binary `objectGUID` never matched: every
|
||||
connected AD user looked gone, and the "more than half" guard is what stopped it disabling them
|
||||
all (the Samba check caught it). `EqualityFilter` with a Buffer value goes out as is; the rest is
|
||||
`FilterParser.parseString(userFilter with {login} → *)`. Sign-in never searches by id, so S4 was
|
||||
unaffected.
|
||||
- Dashboard warning `directory_check` (04 §7.3): setting `system.directoryWarning` (read-only, set
|
||||
by the system), written by a sign-in's `directory_unavailable` and by the job, cleared by a clean
|
||||
run. Settings → Sign-in → Directory shows it too, with the hourly check's note.
|
||||
- The last active admin with a password is kept (logged) when gone, as `applyRole` keeps them.
|
||||
- `test/directory-check.test.ts` (a fake lookup: update, disable, down, the half guard, last
|
||||
admin, directory off), `test/ad-live.test.ts` (`AD_LIVE_URL`, `AD_LIVE_ADMIN_PASS`; users and a
|
||||
nested group made over LDAPS): passed against Samba 4 (`diegogslomp/samba-ad-dc`) on 2026-10-25,
|
||||
LDAPS with a throwaway CA: sign-in by sAMAccountName, UPN and mail, admin through a nested group,
|
||||
the disabled bit refused, `objectGUID` as hex, a deleted user disabled. `ldap-live.test.ts`
|
||||
gained a removed user disabled by the check. The OpenLDAP and FreeIPA presets are still
|
||||
unchecked against a real server.
|
||||
|
||||
## 9. Decided
|
||||
|
||||
- Owner, 2026-10-25: a user connects their OpenID account to their account and signs in with
|
||||
|
||||
@@ -151,6 +151,8 @@ export const SettingsSchemas = {
|
||||
})
|
||||
.nullable()
|
||||
.default(null),
|
||||
/** Why the directory couldn't be used (12 §4.2, §4.5), for the dashboard; null = fine. */
|
||||
'system.directoryWarning': z.string().nullable().default(null),
|
||||
// Not in 02 §3's table but used by the generator (03 §3).
|
||||
'system.transportHash': z.string().nullable().default(null),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user