import { Injectable } from '@nestjs/common'; import { nanoid } from 'nanoid'; import { ExecOutputReturnValue } from 'shelljs'; import { deleteRole, deleteUser, RestClient } from '@directus/sdk'; import { Credential } from './credential.type.js'; import { RedactService } from '../../redact/redact.service.js'; import { assertUuid, UUID_REGEX } from '../../sql/sql-escape.js'; import { DbDriver } from '../../sql/db-driver/db-driver.interface.js'; import { MysqlExecutor } from '../../sql/mysql-executor.type.js'; import { shquote } from '../../util/sh-quote.js'; import argon2 from 'argon2'; /** Runs a shell command inside the Directus container/pod and returns its output. */ export type ExecInDirectus = ( command: string, ) => Promise; /** Builds an authenticated Directus SDK REST client for the given port/token. */ export type GetDirectusClient = ( port: number, token: string, ) => RestClient; @Injectable() export class DirectusUserService { private readonly password: string = nanoid(48); private readonly roleName: string = `migrateus+${nanoid(6)}`; // `example.com` is an RFC 2606 reserved domain with a valid IANA TLD: it // passes Directus' `Joi.string().email()` check (which rejects `.local`) and // can never collide with a real user. private readonly email: string = `${this.roleName}@example.com`; // The token is only known after logging in as the freshly created temp admin. public token: string; private roleId: string; private userId: string; // Stored during setupUser so removeUser can rebuild an authenticated client. private getClient: GetDirectusClient; private port: number; constructor(private readonly redactService: RedactService) { this.redactService.addRedaction(this.password); } /** * Creates an engine-agnostic temporary admin using the Directus CLI (which * talks straight to the database), then logs in as that admin to obtain an * access token. The token is stored on `this.token` for SDK consumers. */ public async setupUser( execInDirectus: ExecInDirectus, getClient: GetDirectusClient, port: number, ): Promise { this.getClient = getClient; this.port = port; // `--app` is required: `roles create --admin` creates an admin policy and // sets `app_access` straight from the flag, so omitting `--app` inserts // null. `directus_policies.app_access` is NOT NULL, so the CLI aborts with // a NOT_NULL_VIOLATION. An admin role having app access is correct anyway. const roleOutput = await execInDirectus( `node /directus/cli.js roles create --role ${shquote(this.roleName)} --admin --app`, ); this.roleId = this.parseCliId(roleOutput, 'temporary admin role id'); const userOutput = await execInDirectus( `node /directus/cli.js users create --email ${shquote(this.email)} --password ${shquote(this.password)} --role ${shquote(this.roleId)}`, ); this.userId = this.parseCliId(userOutput, 'temporary admin user id'); this.token = await this.login(port); this.redactService.addRedaction(this.token); } /** * Extracts the created entity id (a UUID) from Directus CLI output: the last * line that is exactly a UUID. * * Both streams are searched and the id is located by shape rather than by * position, because the CLI output is wrapped in noise on every side: * - the CLI itself prints pino INFO log lines (e.g. "Extensions loaded") * BEFORE the id; * - `az containerapp exec` (ACA) runs under a `script` PTY that muxes both * streams together and brackets the command with its own banner — `Use * ctrl + D to exit.`, `INFO: Connecting…`, `INFO: Successfully connected…` * BEFORE and `Disconnecting…` AFTER. Taking the final line would grab * `Disconnecting…`; matching the UUID shape skips all of it. * * If no UUID is found, the full raw output (stdout, stderr, exit code) is * surfaced in the error — the cryptic CLI/exec failure is otherwise lost. */ private parseCliId(output: ExecOutputReturnValue, label: string): string { const uuids = `${output.stdout}\n${output.stderr}` .split('\n') .map((line) => line.trim()) .filter((line) => UUID_REGEX.test(line)); const candidate = uuids[uuids.length - 1]; if (!candidate) { throw new Error( `Could not parse ${label} from Directus CLI output ` + `(expected a UUID line, found none).\n` + `--- exit code: ${output.code}\n` + `--- stdout:\n${output.stdout || '(empty)'}\n` + `--- stderr:\n${output.stderr || '(empty)'}`, ); } return candidate; } /** * Best-effort cleanup of the temporary admin via the SDK. The role is deleted * first (its deletion does not invalidate the token); the user is deleted last * because removing it kills the token. A failed role delete must not throw — * the leftover empty role is acceptable and is swept by the `clean` command. */ public async removeUser(): Promise { if (!this.userId || !this.token) { return; } const client = this.getClient(this.port, this.token); // Delete the USER first, while the temp admin still has the admin access it // derives from its role's policy. Deleting the role first strips that access // and the user delete then fails with a permission error. User removal is // the security-critical step, so a failure here surfaces. await client.request(deleteUser(this.userId)); // The role (and its admin policy) is now orphaned but inert — no user holds // it. Best-effort delete: the access token's user is gone, so this may fail; // that is acceptable, leftover migrateus roles are swept by `clean`. if (this.roleId) { try { await client.request(deleteRole(this.roleId)); } catch { // token may be invalid post-user-deletion, or the role still referenced } } // Forget the temp admin so a repeated cleanup no-ops instead of re-issuing // the DELETE against a torn-down tunnel. Only reached when the user delete // succeeded, so a failure still leaves the state around for a retry. this.token = undefined; this.userId = undefined; this.roleId = undefined; } private async login(port: number): Promise { const response = await fetch(`http://localhost:${port}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: this.email, password: this.password }), }); if (!response.ok) { throw new Error( `Failed to log in as the temporary Directus admin (HTTP ${response.status})`, ); } const body = (await response.json()) as { data?: { access_token?: string }; }; const token = body?.data?.access_token; if (!token) { throw new Error( 'Directus login succeeded but returned no access_token for the temporary admin', ); } return token; } public async setCredentials( credentials: Credential[], driver: DbDriver, execSql: MysqlExecutor, ) { for (const credential of credentials) { const email = driver.escapeString(credential.email); if (credential.token) { const token = driver.escapeString(credential.token); await execSql( `UPDATE directus_users SET token = ${token} WHERE email = ${email}`, ); } if (credential.password) { const hash = driver.escapeString(await argon2.hash(credential.password)); await execSql( `UPDATE directus_users SET password = ${hash} WHERE email = ${email}`, ); } } } /** Sweeps every Migrateus leftover (users, roles, policies) and returns how * many of each were removed — surfaced in the log for debugging. */ public async cleanUp( driver: DbDriver, execSql: MysqlExecutor, ): Promise<{ users: number; roles: number; policies: number }> { const userIds = ( await execSql( `SELECT id from directus_users WHERE email LIKE 'migrateus%'`, ) ) .split('\n') .filter(Boolean) .map((id) => assertUuid(id, 'directus_users.id')); if (userIds.length > 0) { const escapedIds = userIds.map((id) => driver.escapeString(id)).join(','); await execSql( `UPDATE directus_files SET modified_by = null WHERE modified_by IN (${escapedIds})`, ); } await execSql(`DELETE FROM directus_users WHERE email LIKE 'migrateus%'`); const roleIds = ( await execSql(`SELECT id FROM directus_roles WHERE name LIKE 'migrateus%'`) ) .split('\n') .filter(Boolean) .map((id) => assertUuid(id, 'directus_roles.id')); await execSql(`DELETE FROM directus_roles WHERE name LIKE 'migrateus%'`); // The admin policy created by `roles create --admin --app` is named by // Directus as `Policy for ` — i.e. `Policy for migrateus+…`, NOT // `migrateus…`. Match both so these policies (and their directus_access // rows) are swept; deleting the role cascades the access link, so by the // time we get here orphaned policies are found by name, not by join. const policyMatch = "name LIKE 'migrateus%' OR name LIKE 'Policy for migrateus%'"; const policyCount = ( await execSql(`SELECT id FROM directus_policies WHERE ${policyMatch}`) ) .split('\n') .filter(Boolean).length; if (policyCount > 0) { // Delete via a sub-select on the same name pattern rather than one // round-trip per policy: each `execSql` is a slow, intermittently flaky // `az containerapp exec` on ACA, so keeping the call count flat (2 instead // of 2×N) is what makes the sweep actually complete. Access rows first // (they reference the policy), then the policies. await execSql( `DELETE FROM directus_access WHERE policy IN (SELECT id FROM directus_policies WHERE ${policyMatch})`, ); await execSql(`DELETE FROM directus_policies WHERE ${policyMatch}`); } return { users: userIds.length, roles: roleIds.length, policies: policyCount, }; } }