539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import * as plugins from '../../plugins.js';
|
|
import type { OpsServer } from '../classes.opsserver.js';
|
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
|
|
export interface IJwtData {
|
|
userId: string;
|
|
status: 'loggedIn' | 'loggedOut';
|
|
expiresAt: number;
|
|
}
|
|
|
|
type TAdminUser = {
|
|
id: string;
|
|
username: string;
|
|
email?: string;
|
|
name?: string;
|
|
role: string;
|
|
status?: 'active' | 'disabled';
|
|
authSources?: Array<'local' | 'idp.global'>;
|
|
};
|
|
|
|
export class AdminHandler {
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
|
|
// JWT instance
|
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
|
|
|
// Ephemeral bootstrap users. DB-backed instances may use these only until the
|
|
// database is ready and the first persistent admin account has been created.
|
|
private users = new Map<string, {
|
|
id: string;
|
|
username: string;
|
|
password: string;
|
|
role: string;
|
|
}>();
|
|
|
|
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
|
|
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
|
|
private ownsIdpClient = false;
|
|
|
|
constructor(private opsServerRef: OpsServer) {
|
|
// Add this handler's router to the parent
|
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
}
|
|
|
|
public async initialize(): Promise<void> {
|
|
await this.initializeJwt();
|
|
this.initializeDefaultUsers();
|
|
this.registerHandlers();
|
|
}
|
|
|
|
public async stop(): Promise<void> {
|
|
if (this.ownsIdpClient) {
|
|
await this.idpClient?.stop();
|
|
}
|
|
this.idpClient = undefined;
|
|
this.ownsIdpClient = false;
|
|
}
|
|
|
|
private async initializeJwt(): Promise<void> {
|
|
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
|
await this.smartjwtInstance.init();
|
|
|
|
// For development, create new keypair each time
|
|
// In production, load from storage like cloudly does
|
|
await this.smartjwtInstance.createNewKeyPair();
|
|
}
|
|
|
|
private initializeDefaultUsers(): void {
|
|
const username = process.env.DCROUTER_ADMIN_USERNAME || 'admin';
|
|
const configuredPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
|
const password = configuredPassword || plugins.crypto.randomBytes(24).toString('base64url');
|
|
|
|
const adminId = plugins.uuid.v4();
|
|
this.users.set(adminId, {
|
|
id: adminId,
|
|
username,
|
|
password,
|
|
role: 'admin',
|
|
});
|
|
|
|
if (!configuredPassword) {
|
|
console.warn(`DCRouter generated one-time admin password for ${username}: ${password}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a safe projection of the active user source — excludes password fields.
|
|
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
|
*/
|
|
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
|
const accountState = await this.getPersistentAccountState();
|
|
if (accountState.dbEnabled && !accountState.dbReady) {
|
|
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
|
}
|
|
if (accountState.hasPersistentAdmin) {
|
|
const accounts = await accountState.store!.listAccounts();
|
|
return accounts.map((accountArg) => this.accountToUser(accountArg));
|
|
}
|
|
|
|
return Array.from(this.users.values()).map((user) => ({
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
}));
|
|
}
|
|
|
|
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
|
const accountState = await this.getPersistentAccountState();
|
|
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
|
|
return {
|
|
dbEnabled: accountState.dbEnabled,
|
|
dbReady: accountState.dbReady,
|
|
hasPersistentAdmin: accountState.hasPersistentAdmin,
|
|
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
|
|
ephemeralAdminAvailable: bootstrapAvailable,
|
|
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
|
};
|
|
}
|
|
|
|
public async createInitialAdminUser(optionsArg: {
|
|
email: string;
|
|
name?: string;
|
|
password: string;
|
|
enableIdpGlobalAuth?: boolean;
|
|
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
|
|
const store = this.getAccountStore();
|
|
if (!store) {
|
|
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
|
}
|
|
|
|
if (await store.hasActiveAdminAccount()) {
|
|
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
|
|
}
|
|
|
|
const password = String(optionsArg.password || '');
|
|
if (!password) {
|
|
throw new plugins.typedrequest.TypedResponseError('password is required');
|
|
}
|
|
|
|
const email = String(optionsArg.email || '').trim();
|
|
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
|
if (optionsArg.enableIdpGlobalAuth) {
|
|
authSources.push('idp.global');
|
|
}
|
|
|
|
try {
|
|
const account = await store.createAccount({
|
|
email,
|
|
name: String(optionsArg.name || '').trim() || email,
|
|
role: 'admin',
|
|
authSources,
|
|
password,
|
|
});
|
|
const user = this.accountToUser(account);
|
|
return {
|
|
success: true,
|
|
identity: await this.createIdentityForUser(user),
|
|
user,
|
|
};
|
|
} catch (error) {
|
|
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
|
|
}
|
|
}
|
|
|
|
public async createUser(optionsArg: {
|
|
email: string;
|
|
name?: string;
|
|
role: interfaces.requests.TUserManagementRole;
|
|
password: string;
|
|
enableIdpGlobalAuth?: boolean;
|
|
}): Promise<interfaces.requests.IReq_CreateUser['response']> {
|
|
const store = this.getAccountStore();
|
|
if (!store) {
|
|
return { success: false, message: 'database is not ready' };
|
|
}
|
|
if (!(await store.hasActiveAdminAccount())) {
|
|
return { success: false, message: 'initial admin bootstrap is required before creating users' };
|
|
}
|
|
|
|
const role = optionsArg.role;
|
|
if (role !== 'admin' && role !== 'user') {
|
|
return { success: false, message: 'role must be admin or user' };
|
|
}
|
|
|
|
const password = String(optionsArg.password || '');
|
|
if (!password) {
|
|
return { success: false, message: 'password is required' };
|
|
}
|
|
|
|
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
|
if (optionsArg.enableIdpGlobalAuth) {
|
|
authSources.push('idp.global');
|
|
}
|
|
|
|
try {
|
|
const email = String(optionsArg.email || '').trim();
|
|
const account = await store.createAccount({
|
|
email,
|
|
name: String(optionsArg.name || '').trim() || email,
|
|
role,
|
|
authSources,
|
|
password,
|
|
});
|
|
return { success: true, user: this.accountToUser(account) };
|
|
} catch (error) {
|
|
return { success: false, message: (error as Error).message || 'failed to create user' };
|
|
}
|
|
}
|
|
|
|
public async deleteUser(optionsArg: {
|
|
id: string;
|
|
requestingUserId: string;
|
|
}): Promise<interfaces.requests.IReq_DeleteUser['response']> {
|
|
const store = this.getAccountStore();
|
|
if (!store) {
|
|
return { success: false, message: 'database is not ready' };
|
|
}
|
|
if (!(await store.hasActiveAdminAccount())) {
|
|
return { success: false, message: 'initial admin bootstrap is required before deleting users' };
|
|
}
|
|
|
|
const id = String(optionsArg.id || '').trim();
|
|
if (!id) {
|
|
return { success: false, message: 'user id is required' };
|
|
}
|
|
if (id === optionsArg.requestingUserId) {
|
|
return { success: false, message: 'cannot delete the current user' };
|
|
}
|
|
|
|
const account = await store.getAccountById(id);
|
|
if (!account) {
|
|
return { success: false, message: 'user not found' };
|
|
}
|
|
|
|
if (account.role === 'admin' && account.status === 'active') {
|
|
const activeAdmins = (await store.listAccounts()).filter(
|
|
(accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
|
|
);
|
|
if (activeAdmins.length <= 1) {
|
|
return { success: false, message: 'cannot delete the last active admin' };
|
|
}
|
|
}
|
|
|
|
const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
|
|
if (!doc) {
|
|
return { success: false, message: 'user not found' };
|
|
}
|
|
await doc.delete();
|
|
return { success: true };
|
|
}
|
|
|
|
private registerHandlers(): void {
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
|
'getAdminBootstrapStatus',
|
|
async (_dataArg) => this.getBootstrapStatus()
|
|
)
|
|
);
|
|
|
|
this.opsServerRef.adminRouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
|
|
'createInitialAdminUser',
|
|
async (dataArg) => {
|
|
const isAdmin = await this.adminIdentityGuard.exec({ identity: dataArg.identity });
|
|
if (!isAdmin) {
|
|
throw new plugins.typedrequest.TypedResponseError('admin identity required');
|
|
}
|
|
return this.createInitialAdminUser({
|
|
email: dataArg.email,
|
|
name: dataArg.name,
|
|
password: dataArg.password,
|
|
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
|
});
|
|
}
|
|
)
|
|
);
|
|
|
|
// Admin Login Handler
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
|
'adminLoginWithUsernameAndPassword',
|
|
async (dataArg) => {
|
|
try {
|
|
const user = await this.authenticateUser({
|
|
username: dataArg.username,
|
|
password: dataArg.password,
|
|
authSource: dataArg.authSource,
|
|
});
|
|
if (!user) {
|
|
throw new plugins.typedrequest.TypedResponseError('login failed');
|
|
}
|
|
|
|
return {
|
|
identity: await this.createIdentityForUser(user),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
|
throw error;
|
|
}
|
|
throw new plugins.typedrequest.TypedResponseError('login failed');
|
|
}
|
|
}
|
|
)
|
|
);
|
|
|
|
// Admin Logout Handler
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLogout>(
|
|
'adminLogout',
|
|
async (dataArg) => {
|
|
const identity = await this.validateIdentity(dataArg.identity);
|
|
if (!identity) {
|
|
throw new plugins.typedrequest.TypedResponseError('identity is not valid');
|
|
}
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
)
|
|
);
|
|
|
|
// Verify Identity Handler
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_VerifyIdentity>(
|
|
'verifyIdentity',
|
|
async (dataArg) => {
|
|
const identity = await this.validateIdentity(dataArg.identity);
|
|
return identity ? { valid: true, identity } : { valid: false };
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a guard for valid identity (matching cloudly pattern)
|
|
*/
|
|
public validIdentityGuard = new plugins.smartguard.Guard<{
|
|
identity: interfaces.data.IIdentity;
|
|
}>(
|
|
async (dataArg) => {
|
|
return Boolean(await this.validateIdentity(dataArg.identity));
|
|
},
|
|
{
|
|
failedHint: 'identity is not valid',
|
|
name: 'validIdentityGuard',
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Create a guard for admin identity (matching cloudly pattern)
|
|
*/
|
|
public adminIdentityGuard = new plugins.smartguard.Guard<{
|
|
identity: interfaces.data.IIdentity;
|
|
}>(
|
|
async (dataArg) => {
|
|
const identity = await this.validateIdentity(dataArg.identity);
|
|
return identity?.role === 'admin';
|
|
},
|
|
{
|
|
failedHint: 'user is not admin',
|
|
name: 'adminIdentityGuard',
|
|
}
|
|
);
|
|
|
|
public async validateIdentity(
|
|
identityArg?: interfaces.data.IIdentity,
|
|
): Promise<interfaces.data.IIdentity | null> {
|
|
if (!identityArg?.jwt) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const jwtData = await this.smartjwtInstance.verifyJWTAndGetData(identityArg.jwt);
|
|
if (jwtData.expiresAt < Date.now()) {
|
|
return null;
|
|
}
|
|
if (jwtData.status !== 'loggedIn') {
|
|
return null;
|
|
}
|
|
if (identityArg.expiresAt !== jwtData.expiresAt) {
|
|
return null;
|
|
}
|
|
if (identityArg.userId !== jwtData.userId) {
|
|
return null;
|
|
}
|
|
|
|
const user = await this.resolveUser(jwtData.userId);
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
if (identityArg.role && identityArg.role !== user.role) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
jwt: identityArg.jwt,
|
|
userId: user.id,
|
|
name: user.name || user.username,
|
|
expiresAt: jwtData.expiresAt,
|
|
role: user.role,
|
|
type: 'user',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async authenticateUser(optionsArg: {
|
|
username: string;
|
|
password: string;
|
|
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
|
}): Promise<TAdminUser | null> {
|
|
const accountState = await this.getPersistentAccountState();
|
|
if (accountState.dbEnabled && !accountState.dbReady) {
|
|
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
|
}
|
|
|
|
if (accountState.hasPersistentAdmin) {
|
|
const authService = new plugins.idpSdkServer.AccountAuthService({
|
|
store: accountState.store!,
|
|
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
|
});
|
|
const result = await authService.authenticate({
|
|
email: optionsArg.username,
|
|
password: optionsArg.password,
|
|
authSource: optionsArg.authSource || 'auto',
|
|
});
|
|
return result ? this.accountToUser(result.account) : null;
|
|
}
|
|
|
|
for (const [_, userData] of this.users) {
|
|
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
|
|
return userData;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
|
const accountState = await this.getPersistentAccountState();
|
|
if (accountState.dbEnabled && !accountState.dbReady) {
|
|
return null;
|
|
}
|
|
|
|
if (accountState.hasPersistentAdmin) {
|
|
const account = await accountState.store!.getAccountById(userIdArg);
|
|
if (!account || account.status !== 'active') {
|
|
return null;
|
|
}
|
|
return this.accountToUser(account);
|
|
}
|
|
|
|
return this.users.get(userIdArg) || null;
|
|
}
|
|
|
|
private async getPersistentAccountState(): Promise<{
|
|
dbEnabled: boolean;
|
|
dbReady: boolean;
|
|
store: plugins.idpSdkServer.SmartdataAccountStore | null;
|
|
hasPersistentAdmin: boolean;
|
|
}> {
|
|
const dbEnabled = this.isPersistenceEnabled();
|
|
const store = dbEnabled ? this.getAccountStore() : null;
|
|
const dbReady = !!store;
|
|
const hasPersistentAdmin = store ? await store.hasActiveAdminAccount() : false;
|
|
return { dbEnabled, dbReady, store, hasPersistentAdmin };
|
|
}
|
|
|
|
private isPersistenceEnabled(): boolean {
|
|
return this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
|
}
|
|
|
|
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
|
|
if (!this.isPersistenceEnabled()) {
|
|
return null;
|
|
}
|
|
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
|
if (!dcRouterDb?.isReady()) {
|
|
return null;
|
|
}
|
|
if (!this.accountStore) {
|
|
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
|
|
smartdataDb: dcRouterDb.getDb(),
|
|
});
|
|
}
|
|
return this.accountStore;
|
|
}
|
|
|
|
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
|
|
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
|
|
if (configuredClient) {
|
|
return configuredClient;
|
|
}
|
|
|
|
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
|
if (!this.idpClient) {
|
|
this.idpClient = baseUrl
|
|
? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
|
|
: new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
|
|
this.ownsIdpClient = true;
|
|
}
|
|
return this.idpClient;
|
|
}
|
|
|
|
private isIdpGlobalConfigured(): boolean {
|
|
return true;
|
|
}
|
|
|
|
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
|
|
return {
|
|
id: accountArg.id,
|
|
username: accountArg.email,
|
|
email: accountArg.email,
|
|
name: accountArg.name,
|
|
role: accountArg.role,
|
|
status: accountArg.status,
|
|
authSources: accountArg.authSources,
|
|
};
|
|
}
|
|
|
|
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
|
|
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
|
const jwt = await this.smartjwtInstance.createJWT({
|
|
userId: userArg.id,
|
|
status: 'loggedIn',
|
|
expiresAt: expiresAtTimestamp,
|
|
});
|
|
|
|
return {
|
|
jwt,
|
|
userId: userArg.id,
|
|
name: userArg.name || userArg.username,
|
|
expiresAt: expiresAtTimestamp,
|
|
role: userArg.role,
|
|
type: 'user',
|
|
};
|
|
}
|
|
}
|