my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1/**
2 * LDAP Orphan Account Audit Script
3 *
4 * This script identifies Indiko accounts provisioned via LDAP that no longer exist in LDAP.
5 * Useful for detecting when users have been removed from LDAP but their Indiko accounts remain active.
6 *
7 * Usage: bun scripts/audit-ldap-orphans.ts [--suspend | --deactivate | --dry-run]
8 *
9 * Flags:
10 * --dry-run Show what would be done without making changes (default)
11 * --suspend Set status to 'suspended' for orphaned accounts
12 * --deactivate Set status to 'inactive' for orphaned accounts
13 */
14
15import { Database } from "bun:sqlite";
16import * as path from "node:path";
17import { authenticate } from "ldap-authentication";
18
19// Load database
20const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db");
21const db = new Database(dbPath);
22
23// Configuration from environment
24const LDAP_URL = process.env.LDAP_URL || "ldap://localhost:389";
25const LDAP_ADMIN_DN = process.env.LDAP_ADMIN_DN;
26const LDAP_ADMIN_PASSWORD = process.env.LDAP_ADMIN_PASSWORD;
27const LDAP_USER_SEARCH_BASE =
28 process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com";
29const LDAP_USERNAME_ATTRIBUTE = process.env.LDAP_USERNAME_ATTRIBUTE || "uid";
30
31interface LdapUser {
32 username: string;
33 id: number;
34 status: string;
35 created_at: number;
36}
37
38interface AuditResult {
39 total: number;
40 active: number;
41 orphaned: number;
42 errors: number;
43 orphanedUsers: Array<{
44 username: string;
45 id: number;
46 status: string;
47 createdAt: number;
48 }>;
49}
50
51async function checkLdapUser(username: string): Promise<boolean> {
52 try {
53 const user = await authenticate({
54 ldapOpts: {
55 url: LDAP_URL,
56 },
57 adminDn: LDAP_ADMIN_DN,
58 adminPassword: LDAP_ADMIN_PASSWORD,
59 userSearchBase: LDAP_USER_SEARCH_BASE,
60 usernameAttribute: LDAP_USERNAME_ATTRIBUTE,
61 username: username,
62 verifyUserExists: true,
63 });
64 return !!user;
65 } catch (error) {
66 // User not found or invalid credentials (expected for non-existence check)
67 return false;
68 }
69}
70
71async function auditLdapAccounts(): Promise<AuditResult> {
72 console.log("🔍 Starting LDAP orphan account audit...\n");
73
74 // Get all LDAP-provisioned users
75 const ldapUsers = db
76 .query(
77 "SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1",
78 )
79 .all() as LdapUser[];
80
81 const result: AuditResult = {
82 total: ldapUsers.length,
83 active: 0,
84 orphaned: 0,
85 errors: 0,
86 orphanedUsers: [],
87 };
88
89 console.log(`Found ${result.total} LDAP-provisioned accounts\n`);
90
91 // Check each user against LDAP
92 for (const user of ldapUsers) {
93 process.stdout.write(`Checking ${user.username}... `);
94
95 try {
96 const existsInLdap = await checkLdapUser(user.username);
97
98 if (existsInLdap) {
99 console.log("✅ Found in LDAP");
100 result.active++;
101 } else {
102 console.log("❌ NOT FOUND in LDAP");
103 result.orphaned++;
104 result.orphanedUsers.push({
105 username: user.username,
106 id: user.id,
107 status: user.status,
108 createdAt: user.created_at,
109 });
110 }
111 } catch (error) {
112 console.log("⚠️ Error checking LDAP");
113 result.errors++;
114 console.error(
115 ` Error: ${error instanceof Error ? error.message : String(error)}`,
116 );
117 }
118 }
119
120 return result;
121}
122
123function printReport(result: AuditResult): void {
124 console.log(`\n${"=".repeat(60)}`);
125 console.log("LDAP ORPHAN ACCOUNT AUDIT REPORT");
126 console.log(`${"=".repeat(60)}\n`);
127
128 console.log(`Total LDAP-provisioned accounts: ${result.total}`);
129 console.log(`Active in LDAP: ${result.active}`);
130 console.log(`Orphaned (missing from LDAP): ${result.orphaned}`);
131 console.log(`Check errors: ${result.errors}`);
132
133 if (result.orphaned === 0) {
134 console.log("\n✅ No orphaned accounts found!");
135 return;
136 }
137
138 console.log(`\n${"-".repeat(60)}`);
139 console.log("ORPHANED ACCOUNTS:");
140 console.log(`${"-".repeat(60)}\n`);
141
142 result.orphanedUsers.forEach((user, idx) => {
143 console.log(`${idx + 1}. ${user.username}`);
144 console.log(
145 ` ID: ${user.id} | Status: ${user.status} | Created: ${new Date(user.createdAt * 1000).toISOString().split("T")[0]}`,
146 );
147 });
148}
149
150async function updateOrphanedAccounts(
151 result: AuditResult,
152 action: "suspend" | "deactivate",
153): Promise<void> {
154 const newStatus = action === "suspend" ? "suspended" : "inactive";
155
156 console.log(
157 `\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`,
158 );
159
160 for (const user of result.orphanedUsers) {
161 db.query("UPDATE users SET status = ? WHERE id = ?").run(
162 newStatus,
163 user.id,
164 );
165 console.log(` Updated: ${user.username}`);
166 }
167
168 console.log(`\n✅ Updated ${result.orphaned} account(s)`);
169}
170
171async function main() {
172 // Validate LDAP configuration
173 if (!LDAP_ADMIN_DN || !LDAP_ADMIN_PASSWORD) {
174 console.error(
175 "❌ Error: LDAP_ADMIN_DN and LDAP_ADMIN_PASSWORD environment variables are required",
176 );
177 process.exit(1);
178 }
179
180 const args = process.argv.slice(2);
181 const dryRun = args.includes("--dry-run") || args.length === 0;
182 const shouldSuspend = args.includes("--suspend");
183 const shouldDeactivate = args.includes("--deactivate");
184
185 if (dryRun) {
186 console.log("🔄 Running in DRY-RUN mode (no changes will be made)\n");
187 }
188
189 try {
190 const result = await auditLdapAccounts();
191 printReport(result);
192
193 if (!dryRun && result.orphaned > 0) {
194 if (shouldSuspend) {
195 await updateOrphanedAccounts(result, "suspend");
196 } else if (shouldDeactivate) {
197 await updateOrphanedAccounts(result, "deactivate");
198 } else {
199 console.log(
200 "\n⚠️ No action specified. Use --suspend or --deactivate to update accounts.",
201 );
202 }
203 }
204
205 process.exit(0);
206 } catch (error) {
207 console.error(
208 "\n❌ Audit failed:",
209 error instanceof Error ? error.message : String(error),
210 );
211 process.exit(1);
212 }
213}
214
215main();