feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication
This commit is contained in:
@@ -113,6 +113,9 @@ export class OpsServer {
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.adminHandler) {
|
||||
await this.adminHandler.stop();
|
||||
}
|
||||
// Clean up log handler streams and push destination before stopping the server
|
||||
if (this.logsHandler) {
|
||||
this.logsHandler.cleanup();
|
||||
|
||||
@@ -8,19 +8,33 @@ export interface IJwtData {
|
||||
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>;
|
||||
|
||||
// Simple in-memory user storage (in production, use proper database)
|
||||
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
|
||||
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
|
||||
@@ -32,6 +46,14 @@ export class AdminHandler {
|
||||
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();
|
||||
@@ -61,54 +83,120 @@ export class AdminHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a safe projection of the users Map — excludes password fields.
|
||||
* Return a safe projection of the active user source — excludes password fields.
|
||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||
*/
|
||||
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
||||
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
||||
if (await this.hasPersistentAdminAccount()) {
|
||||
const store = this.getAccountStore();
|
||||
const accounts = await 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 dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
||||
const store = this.getAccountStore();
|
||||
const dbReady = !!store;
|
||||
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
|
||||
return {
|
||||
dbEnabled,
|
||||
dbReady,
|
||||
hasPersistentAdmin,
|
||||
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
|
||||
ephemeralAdminAvailable: !hasPersistentAdmin,
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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) => 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 {
|
||||
// Find user by username and password
|
||||
let user: { id: string; username: string; password: string; role: string } | null = null;
|
||||
for (const [_, userData] of this.users) {
|
||||
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
||||
user = userData;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.authenticateUser({
|
||||
username: dataArg.username,
|
||||
password: dataArg.password,
|
||||
authSource: dataArg.authSource,
|
||||
});
|
||||
if (!user) {
|
||||
throw new plugins.typedrequest.TypedResponseError('login failed');
|
||||
}
|
||||
|
||||
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
||||
|
||||
const jwt = await this.smartjwtInstance.createJWT({
|
||||
userId: user.id,
|
||||
status: 'loggedIn',
|
||||
expiresAt: expiresAtTimestamp,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
identity: {
|
||||
jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
expiresAt: expiresAtTimestamp,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
},
|
||||
identity: await this.createIdentityForUser(user),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
||||
@@ -162,8 +250,7 @@ export class AdminHandler {
|
||||
};
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = this.users.get(jwtData.userId);
|
||||
const user = await this.resolveUser(jwtData.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
@@ -175,7 +262,7 @@ export class AdminHandler {
|
||||
identity: {
|
||||
jwt: dataArg.identity.jwt,
|
||||
userId: user.id,
|
||||
name: user.username,
|
||||
name: user.name || user.username,
|
||||
expiresAt: jwtData.expiresAt,
|
||||
role: user.role,
|
||||
type: 'user',
|
||||
@@ -224,6 +311,15 @@ export class AdminHandler {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await this.resolveUser(jwtData.userId);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dataArg.identity.role && dataArg.identity.role !== user.role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
@@ -256,4 +352,120 @@ export class AdminHandler {
|
||||
name: 'adminIdentityGuard',
|
||||
}
|
||||
);
|
||||
|
||||
private async authenticateUser(optionsArg: {
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||
}): Promise<TAdminUser | null> {
|
||||
if (await this.hasPersistentAdminAccount()) {
|
||||
const store = this.getAccountStore();
|
||||
const authService = new plugins.idpSdkServer.AccountAuthService({
|
||||
store: 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> {
|
||||
if (await this.hasPersistentAdminAccount()) {
|
||||
const account = await this.getAccountStore()!.getAccountById(userIdArg);
|
||||
if (!account || account.status !== 'active') {
|
||||
return null;
|
||||
}
|
||||
return this.accountToUser(account);
|
||||
}
|
||||
|
||||
return this.users.get(userIdArg) || null;
|
||||
}
|
||||
|
||||
private async hasPersistentAdminAccount(): Promise<boolean> {
|
||||
const store = this.getAccountStore();
|
||||
return store ? store.hasActiveAdminAccount() : false;
|
||||
}
|
||||
|
||||
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
|
||||
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
|
||||
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 (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.idpClient) {
|
||||
this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
|
||||
this.ownsIdpClient = true;
|
||||
}
|
||||
return this.idpClient;
|
||||
}
|
||||
|
||||
private isIdpGlobalConfigured(): boolean {
|
||||
return !!(
|
||||
this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient ||
|
||||
this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl ||
|
||||
process.env.DCROUTER_IDP_GLOBAL_URL
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class UsersHandler {
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
|
||||
'listUsers',
|
||||
async (_dataArg) => {
|
||||
const users = this.opsServerRef.adminHandler.listUsers();
|
||||
const users = await this.opsServerRef.adminHandler.listUsers();
|
||||
return { users };
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user