Skip to content

Commit e1fe45a

Browse files
authored
feat(admin): bulk user import for migrations (#340)
* feat(admin): bulk user import for migrations Adds POST /admin/users/import and the user_external_ids table. Rows are matched on the source system id and then on email, applied one at a time, and only ever add roles and memberships. Admin roles are refused and an existing account's email is never changed. Closes #336 * fix(admin): do not link an external id to an existing administrator on import * chore(deps): bump @seamless-auth/types to 0.24.0 --------- Co-authored-by: Brandon Corbett <Bccorb@users.noreply.github.com>
1 parent 9f3a62b commit e1fe45a

17 files changed

Lines changed: 1619 additions & 19 deletions

File tree

‎.changeset/admin-user-import.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'seamless-auth-api': minor
3+
---
4+
5+
Add `POST /admin/users/import` for moving users across from another identity system. It takes up to 200 rows per request, matches each on the source system's id and then on email, and applies each row on its own so one rejected row does not stop the batch. `dryRun` reports the outcome without writing.
6+
7+
Imports carry no credentials: an imported user stays unverified until they register with the imported email. Roles and organization memberships are only ever added, an existing account's email is never changed, admin roles are refused, and an external id is never linked to an existing account that holds an admin role. Each created or updated account is recorded as `admin_user_imported` and each batch as `admin_user_import_completed`, against the acting admin.
8+
9+
Adds the `user_external_ids` table (migration `20261005120000`). Requires `@seamless-auth/types` 0.24.0.

‎openapi.json‎

Lines changed: 272 additions & 10 deletions
Large diffs are not rendered by default.

‎package-lock.json‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
"@seamless-auth/messaging": "^0.1.0",
5353
"@seamless-auth/messaging-aws": "^0.1.0",
5454
"@seamless-auth/messaging-twilio": "^0.1.0",
55-
"@seamless-auth/types": "^0.22.0",
55+
"@seamless-auth/types": "^0.24.0",
5656
"@simplewebauthn/server": "^13.3.3",
5757
"base64url": "^3.0.1",
5858
"bcrypt-ts": "^9.0.2",

‎src/controllers/admin.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { AdminUserListQuerySchema } from '../schemas/admin.query.js';
1919
import {
2020
CreateUserSchema,
2121
DeviceReplacementRecoverySchema,
22+
ImportUsersRequestSchema,
2223
UpdateUserSchema,
2324
} from '../schemas/admin.requests.js';
2425
import { authEventTypesFor, SUSPICIOUS_EVENT_TYPES } from '../schemas/authEvent.types.js';
@@ -31,6 +32,7 @@ import {
3132
import { serializeAuthEvents } from '../services/authEventSerialization.js';
3233
import { AuthEventService } from '../services/authEventService.js';
3334
import { hardRevokeSession } from '../services/sessionService.js';
35+
import { importUsers as runUserImport } from '../services/userImportService.js';
3436
import type { AuthenticatedRequest } from '../types/types.js';
3537
import { RouteRequest, ServiceRequest } from '../types/types.js';
3638
import getLogger from '../utils/logger.js';
@@ -179,6 +181,42 @@ export const createUser = async (req: Request, res: Response) => {
179181
}
180182
};
181183

184+
export const importUsers = async (req: Request, res: Response) => {
185+
const request = ImportUsersRequestSchema.parse(req.body);
186+
const response = await runUserImport(request);
187+
188+
if (!response.dryRun) {
189+
const actorUserId = actingAdminId(req);
190+
191+
for (const result of response.results) {
192+
if (result.status !== 'created' && result.status !== 'updated') continue;
193+
194+
await AuthEventService.log({
195+
userId: result.userId ?? null,
196+
actorUserId,
197+
subjectEmail: result.email,
198+
type: 'admin_user_imported',
199+
req,
200+
metadata: {
201+
source: response.source,
202+
status: result.status,
203+
changes: result.changes ?? [],
204+
...(result.externalId ? { externalId: result.externalId } : {}),
205+
},
206+
});
207+
}
208+
209+
await AuthEventService.log({
210+
actorUserId,
211+
type: 'admin_user_import_completed',
212+
req,
213+
metadata: { source: response.source, summary: response.summary },
214+
});
215+
}
216+
217+
return res.status(200).json(response);
218+
};
219+
182220
export const deleteUser = async (req: ServiceRequest, res: Response) => {
183221
logger.info('Internal deletion call made.');
184222
const { userId } = req.body ?? {};

0 commit comments

Comments
 (0)