feat(opsserver-admin): add persisted admin bootstrap flow with optional idp.global authentication
This commit is contained in:
+1
-1
@@ -77,7 +77,7 @@
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"engine": "tsdocker"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
- Validate vpnConfig.serverEndpoint, require persisted config managers for VPN startup, and normalize WireGuard AllowedIPs during client creation, export, and key rotation
|
||||
- Switch smartvpn server setup to wireguard transport with a localhost-only listener and await async server stop operations consistently
|
||||
|
||||
### Features
|
||||
|
||||
- add persisted admin bootstrap flow with optional idp.global authentication (opsserver-admin)
|
||||
- introduces bootstrap status and initial admin creation endpoints for OpsServer
|
||||
- switches admin authentication from ephemeral-only users to database-backed accounts when a persistent admin exists
|
||||
- adds optional idp.global login support for admin accounts and exposes auth source metadata in user listings
|
||||
- updates the web dashboard to prompt creation of the first persisted admin account
|
||||
- adds integration coverage for bootstrap, persisted login, identity invalidation, and user listing behavior
|
||||
|
||||
## 2026-05-09 - 13.28.0 - feat(gateway-clients)
|
||||
add managed gateway client administration and token-bound route ownership
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@design.estate/dees-catalog": "^3.81.0",
|
||||
"@design.estate/dees-element": "^2.2.4",
|
||||
"@idp.global/sdk": "^1.2.0",
|
||||
"@push.rocks/lik": "^6.4.1",
|
||||
"@push.rocks/projectinfo": "^5.1.0",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
|
||||
Generated
+49
@@ -29,6 +29,9 @@ importers:
|
||||
'@design.estate/dees-element':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
'@idp.global/sdk':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)
|
||||
'@push.rocks/lik':
|
||||
specifier: ^6.4.1
|
||||
version: 6.4.1
|
||||
@@ -591,6 +594,9 @@ packages:
|
||||
resolution: {integrity: sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@idp.global/sdk@1.2.0':
|
||||
resolution: {integrity: sha512-L1SUh+wt9dKZ9DzX97M0wrJ080PF3sj1sEtmAOM7A67ZYs0RecCciFB3D5qspOBBVlsw+L4lPmOWv3j720lVTQ==}
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1372,6 +1378,9 @@ packages:
|
||||
'@push.rocks/taskbuffer@8.0.2':
|
||||
resolution: {integrity: sha512-SRCAzrSHysW5XEjwZ494V60ybdpOo/s96jDD3sn7SkYolzg2Pboh+SW5Q7SVNcdkP4b9wCEizOYe9CB3vj3W6w==}
|
||||
|
||||
'@push.rocks/webjwt@1.0.10':
|
||||
resolution: {integrity: sha512-+KzM6/v3Y/8uXBE8JMNBRcYRtXdRywpbX0CrJVfqS00/x/2ZnLvWy0ZZtrvwkQZvrQvfNFF7xgt4+m91xmVKhQ==}
|
||||
|
||||
'@push.rocks/webrequest@4.0.5':
|
||||
resolution: {integrity: sha512-wVSCaXqJ9Vh+rbwVz0wDl46dYz4rnwwSrm5vbVXKbuH6oKTPF0YRoujeJPqRltIn64RVGdLeY9/6ix+ZCrzhsg==}
|
||||
|
||||
@@ -1593,6 +1602,9 @@ packages:
|
||||
'@serve.zone/interfaces@5.5.0':
|
||||
resolution: {integrity: sha512-SZH4sKxBhfX+xF7zPFcHtyWdXMz7XINP5X9tqtLKPa3rJd5XkoeOFsbgDxWfeuBkCGJglvY2FI24oCPexy5acg==}
|
||||
|
||||
'@serve.zone/interfaces@file:../interfaces':
|
||||
resolution: {directory: ../interfaces, type: directory}
|
||||
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
resolution: {integrity: sha512-k3n+AF1rNybiKPlHHyhwCVEF0/T7eZD46kNn7JlEJPCxfUy09mjkpwDQ2CzaUkppqNgFOAYXgAKqjDqpJ27RvA==}
|
||||
|
||||
@@ -5182,6 +5194,33 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@idp.global/sdk@1.2.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)':
|
||||
dependencies:
|
||||
'@api.global/typedrequest': 3.3.0
|
||||
'@api.global/typedsocket': 4.1.2(@push.rocks/smartserve@2.0.4)
|
||||
'@idp.global/interfaces': '@serve.zone/interfaces@file:../interfaces'
|
||||
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
|
||||
'@push.rocks/smartjson': 6.0.1
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.2.3
|
||||
'@push.rocks/smarturl': 3.1.0
|
||||
'@push.rocks/webjwt': 1.0.10
|
||||
'@push.rocks/webstore': 2.0.22
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@nuxt/kit'
|
||||
- '@push.rocks/smartserve'
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- react
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.34.5':
|
||||
@@ -6645,6 +6684,10 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/webjwt@1.0.10':
|
||||
dependencies:
|
||||
'@push.rocks/smartstring': 4.1.1
|
||||
|
||||
'@push.rocks/webrequest@4.0.5':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
@@ -6846,6 +6889,12 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/interfaces@file:../interfaces':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/remoteingress@4.17.1':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.4
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import { OpsServer } from '../ts/opsserver/index.js';
|
||||
import { DcRouterDb } from '../ts/db/index.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
const testPort = 3110;
|
||||
const baseUrl = `http://localhost:${testPort}/typedrequest`;
|
||||
const bootstrapPassword = 'temporary-bootstrap-password';
|
||||
const persistedPassword = 'persisted-admin-password';
|
||||
|
||||
let previousAdminPassword: string | undefined;
|
||||
let opsServer: OpsServer;
|
||||
let testDb: DcRouterDb;
|
||||
let storagePath: string;
|
||||
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||
let persistedIdentity: interfaces.data.IIdentity;
|
||||
|
||||
const createStatusRequest = () => new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||
baseUrl,
|
||||
'getAdminBootstrapStatus',
|
||||
);
|
||||
|
||||
const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
baseUrl,
|
||||
'adminLoginWithUsernameAndPassword',
|
||||
);
|
||||
|
||||
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
||||
previousAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = bootstrapPassword;
|
||||
|
||||
storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
testDb = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await testDb.start();
|
||||
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
const fakeDcRouter = {
|
||||
options: {
|
||||
opsServerPort: testPort,
|
||||
dbConfig: { enabled: true },
|
||||
adminAuth: {
|
||||
idpClient: {
|
||||
loginWithEmailAndPassword: async () => ({
|
||||
jwt: 'idp-jwt',
|
||||
refreshToken: 'idp-refresh-token',
|
||||
user: {
|
||||
id: 'idp-user-1',
|
||||
data: {
|
||||
name: 'Wrong IdP User',
|
||||
username: 'wrong@example.com',
|
||||
email: 'wrong@example.com',
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
stop: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
typedrouter: new plugins.typedrequest.TypedRouter(),
|
||||
dcRouterDb: testDb,
|
||||
};
|
||||
|
||||
opsServer = new OpsServer(fakeDcRouter as any);
|
||||
await opsServer.start();
|
||||
});
|
||||
|
||||
tap.test('reports bootstrap required without auto-persisting an admin', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.dbEnabled).toEqual(true);
|
||||
expect(status.dbReady).toEqual(true);
|
||||
expect(status.hasPersistentAdmin).toEqual(false);
|
||||
expect(status.needsBootstrap).toEqual(true);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected bootstrap login identity');
|
||||
}
|
||||
bootstrapIdentity = response.identity;
|
||||
expect(bootstrapIdentity.role).toEqual('admin');
|
||||
});
|
||||
|
||||
tap.test('creates the initial persisted admin explicitly', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_CreateInitialAdminUser>(
|
||||
baseUrl,
|
||||
'createInitialAdminUser',
|
||||
);
|
||||
|
||||
const response = await request.fire({
|
||||
identity: bootstrapIdentity,
|
||||
email: 'Admin@Example.com',
|
||||
name: 'Persisted Admin',
|
||||
password: persistedPassword,
|
||||
enableIdpGlobalAuth: true,
|
||||
});
|
||||
|
||||
expect(response.success).toEqual(true);
|
||||
expect(response.user?.role).toEqual('admin');
|
||||
expect(response.user?.authSources).toContain('local');
|
||||
expect(response.user?.authSources).toContain('idp.global');
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin identity');
|
||||
}
|
||||
persistedIdentity = response.identity;
|
||||
});
|
||||
|
||||
tap.test('disables bootstrap mode after persisted admin exists', async () => {
|
||||
const status = await createStatusRequest().fire({});
|
||||
|
||||
expect(status.hasPersistentAdmin).toEqual(true);
|
||||
expect(status.needsBootstrap).toEqual(false);
|
||||
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin after persisted admin creation', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin',
|
||||
password: bootstrapPassword,
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('rejects the old temporary admin identity after persisted admin creation', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
baseUrl,
|
||||
'verifyIdentity',
|
||||
);
|
||||
const response = await request.fire({ identity: bootstrapIdentity });
|
||||
|
||||
expect(response.valid).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('authenticates the persisted admin locally by normalized email', async () => {
|
||||
const response = await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: persistedPassword,
|
||||
authSource: 'local',
|
||||
});
|
||||
|
||||
if (!response.identity) {
|
||||
throw new Error('Expected persisted admin login identity');
|
||||
}
|
||||
expect(response.identity.userId).toEqual(persistedIdentity.userId);
|
||||
});
|
||||
|
||||
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
|
||||
let rejected = false;
|
||||
try {
|
||||
await createLoginRequest().fire({
|
||||
username: 'admin@example.com',
|
||||
password: 'idp-password',
|
||||
authSource: 'idp.global',
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
|
||||
expect(rejected).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('lists persisted users without password material', async () => {
|
||||
const request = new TypedRequest<interfaces.requests.IReq_ListUsers>(baseUrl, 'listUsers');
|
||||
const response = await request.fire({ identity: persistedIdentity });
|
||||
|
||||
expect(response.users.length).toEqual(1);
|
||||
expect(response.users[0].email).toEqual('Admin@Example.com');
|
||||
expect((response.users[0] as any).password).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
||||
await opsServer.stop();
|
||||
await testDb.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
|
||||
if (previousAdminPassword === undefined) {
|
||||
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||
} else {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = previousAdminPassword;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -167,6 +167,14 @@ export interface IDcRouterOptions {
|
||||
/** Port for the OpsServer web UI (default: 3000) */
|
||||
opsServerPort?: number;
|
||||
|
||||
/** Optional OpsServer account authentication settings. */
|
||||
adminAuth?: {
|
||||
/** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
|
||||
idpGlobalUrl?: string;
|
||||
/** Test/integration hook for injecting an idp.global-compatible password client. */
|
||||
idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
|
||||
};
|
||||
|
||||
remoteIngressConfig?: {
|
||||
/** Enable remote ingress hub (default: false) */
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -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,13 +8,23 @@ 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;
|
||||
@@ -22,6 +32,10 @@ export class AdminHandler {
|
||||
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);
|
||||
@@ -33,6 +47,14 @@ export class AdminHandler {
|
||||
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();
|
||||
@@ -61,10 +83,16 @@ 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,
|
||||
@@ -72,43 +100,103 @@ export class AdminHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
),
|
||||
|
||||
@@ -41,6 +41,13 @@ export {
|
||||
typedsocket,
|
||||
}
|
||||
|
||||
// @idp.global scope
|
||||
import * as idpSdkServer from '@idp.global/sdk/server';
|
||||
|
||||
export {
|
||||
idpSdkServer,
|
||||
}
|
||||
|
||||
// @push.rocks scope
|
||||
import * as projectinfo from '@push.rocks/projectinfo';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
|
||||
export type TAdminLoginAuthSource = 'auto' | 'local' | 'idp.global';
|
||||
|
||||
export interface IAdminUserProjection {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
}
|
||||
|
||||
// Admin Login
|
||||
export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
@@ -10,12 +22,50 @@ export interface IReq_AdminLoginWithUsernameAndPassword extends plugins.typedreq
|
||||
request: {
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: TAdminLoginAuthSource;
|
||||
};
|
||||
response: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
};
|
||||
}
|
||||
|
||||
// Admin bootstrap status
|
||||
export interface IReq_GetAdminBootstrapStatus extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetAdminBootstrapStatus
|
||||
> {
|
||||
method: 'getAdminBootstrapStatus';
|
||||
request: {};
|
||||
response: {
|
||||
dbEnabled: boolean;
|
||||
dbReady: boolean;
|
||||
hasPersistentAdmin: boolean;
|
||||
needsBootstrap: boolean;
|
||||
ephemeralAdminAvailable: boolean;
|
||||
idpGlobalConfigured: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Create the first persisted admin account. Requires the bootstrap/ephemeral admin identity.
|
||||
export interface IReq_CreateInitialAdminUser extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_CreateInitialAdminUser
|
||||
> {
|
||||
method: 'createInitialAdminUser';
|
||||
request: {
|
||||
identity: authInterfaces.IIdentity;
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
identity?: authInterfaces.IIdentity;
|
||||
user?: IAdminUserProjection;
|
||||
};
|
||||
}
|
||||
|
||||
// Admin Logout
|
||||
export interface IReq_AdminLogout extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as authInterfaces from '../data/auth.js';
|
||||
import type { IAdminUserProjection } from './admin.js';
|
||||
|
||||
/**
|
||||
* List all OpsServer users (admin-only, read-only).
|
||||
@@ -14,10 +15,6 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement
|
||||
identity: authInterfaces.IIdentity;
|
||||
};
|
||||
response: {
|
||||
users: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}>;
|
||||
users: IAdminUserProjection[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface ILoginState {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export type IAdminBootstrapStatus = interfaces.requests.IReq_GetAdminBootstrapStatus['response'];
|
||||
|
||||
export interface IStatsState {
|
||||
serverStats: interfaces.data.IServerStats | null;
|
||||
emailStats: interfaces.data.IEmailStats | null;
|
||||
@@ -312,7 +314,11 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
||||
export interface IUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: string;
|
||||
status?: 'active' | 'disabled';
|
||||
authSources?: Array<'local' | 'idp.global'>;
|
||||
}
|
||||
|
||||
export interface IUsersState {
|
||||
@@ -351,6 +357,7 @@ const getActionContext = (): IActionContext => {
|
||||
export const loginAction = loginStatePart.createAction<{
|
||||
username: string;
|
||||
password: string;
|
||||
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||
}>(async (statePartArg, dataArg): Promise<ILoginState> => {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_AdminLoginWithUsernameAndPassword
|
||||
@@ -360,6 +367,7 @@ export const loginAction = loginStatePart.createAction<{
|
||||
const response = await typedRequest.fire({
|
||||
username: dataArg.username,
|
||||
password: dataArg.password,
|
||||
authSource: dataArg.authSource,
|
||||
});
|
||||
|
||||
if (response.identity) {
|
||||
@@ -375,6 +383,47 @@ export const loginAction = loginStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export async function getAdminBootstrapStatus(): Promise<IAdminBootstrapStatus> {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAdminBootstrapStatus
|
||||
>('/typedrequest', 'getAdminBootstrapStatus');
|
||||
|
||||
return request.fire({});
|
||||
}
|
||||
|
||||
export async function createInitialAdminUser(optionsArg: {
|
||||
email: string;
|
||||
name?: string;
|
||||
password: string;
|
||||
enableIdpGlobalAuth?: boolean;
|
||||
}) {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) {
|
||||
throw new Error('No identity available for admin bootstrap');
|
||||
}
|
||||
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_CreateInitialAdminUser
|
||||
>('/typedrequest', 'createInitialAdminUser');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
email: optionsArg.email,
|
||||
name: optionsArg.name,
|
||||
password: optionsArg.password,
|
||||
enableIdpGlobalAuth: optionsArg.enableIdpGlobalAuth,
|
||||
});
|
||||
|
||||
if (response.identity) {
|
||||
loginStatePart.setState({
|
||||
identity: response.identity,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Logout Action — always clears state, even if identity is expired/missing
|
||||
export const logoutAction = loginStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
|
||||
@@ -66,6 +66,9 @@ export class OpsDashboard extends DeesElement {
|
||||
isLoggedIn: false,
|
||||
};
|
||||
|
||||
private bootstrapStepper?: any;
|
||||
private bootstrapCheckPromise?: Promise<void>;
|
||||
|
||||
@state() accessor uiState: appstate.IUiState = {
|
||||
activeView: 'overview',
|
||||
activeSubview: null,
|
||||
@@ -336,6 +339,7 @@ export class OpsDashboard extends DeesElement {
|
||||
await (simpleLogin as any).switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
await this.ensureAdminBootstrap();
|
||||
} else {
|
||||
// Server rejected the JWT — clear state, show login
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
@@ -370,10 +374,106 @@ export class OpsDashboard extends DeesElement {
|
||||
await simpleLogin!.switchToSlottedContent();
|
||||
await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
|
||||
await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
|
||||
await this.ensureAdminBootstrap();
|
||||
} else {
|
||||
form!.setStatus('error', 'Login failed!');
|
||||
await domtools.convenience.smartdelay.delayFor(2000);
|
||||
form!.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminBootstrap(): Promise<void> {
|
||||
if (!this.loginState.identity || this.bootstrapStepper?.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this.bootstrapCheckPromise) {
|
||||
return this.bootstrapCheckPromise;
|
||||
}
|
||||
|
||||
this.bootstrapCheckPromise = (async () => {
|
||||
try {
|
||||
const status = await appstate.getAdminBootstrapStatus();
|
||||
if (status.needsBootstrap) {
|
||||
await this.showAdminBootstrapStepper(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Admin bootstrap status check failed:', error);
|
||||
} finally {
|
||||
this.bootstrapCheckPromise = undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.bootstrapCheckPromise;
|
||||
}
|
||||
|
||||
private async showAdminBootstrapStepper(statusArg: appstate.IAdminBootstrapStatus): Promise<void> {
|
||||
const { DeesStepper } = await import('@design.estate/dees-catalog');
|
||||
this.bootstrapStepper = await DeesStepper.createAndShow({
|
||||
cancelable: false,
|
||||
steps: [
|
||||
{
|
||||
title: 'Create Persisted Admin',
|
||||
content: html`
|
||||
<div style="display: grid; gap: 16px; color: var(--dees-color-text-secondary); font-size: 14px; line-height: 1.5;">
|
||||
<p style="margin: 0;">
|
||||
This router is currently using the temporary bootstrap admin. Create the first persisted admin account to continue.
|
||||
</p>
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'email'} .label=${'Admin email'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
|
||||
<dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
|
||||
<dees-input-checkbox
|
||||
.key=${'enableIdpGlobalAuth'}
|
||||
.label=${'Allow idp.global login for this email'}
|
||||
.description=${statusArg.idpGlobalConfigured
|
||||
? 'The local account remains authoritative; idp.global only verifies identity.'
|
||||
: 'Requires DCROUTER_IDP_GLOBAL_URL before idp.global logins can work.'}
|
||||
></dees-input-checkbox>
|
||||
</dees-form>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'Create admin',
|
||||
action: async (stepperArg: any) => {
|
||||
const form = stepperArg.shadowRoot?.querySelector('.selected dees-form') as any;
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
const email = String(formData.email || '').trim();
|
||||
const name = String(formData.name || '').trim();
|
||||
const password = String(formData.password || '');
|
||||
const passwordConfirm = String(formData.passwordConfirm || '');
|
||||
|
||||
if (!email || !password) {
|
||||
form.setStatus?.('error', 'Email and password are required.');
|
||||
return;
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
form.setStatus?.('error', 'Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
form.setStatus?.('pending', 'Creating persisted admin...');
|
||||
await appstate.createInitialAdminUser({
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
enableIdpGlobalAuth: Boolean(formData.enableIdpGlobalAuth),
|
||||
});
|
||||
form.setStatus?.('success', 'Persisted admin created.');
|
||||
await stepperArg.destroy();
|
||||
this.bootstrapStepper = undefined;
|
||||
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
||||
} catch (error) {
|
||||
form.setStatus?.('error', error instanceof Error ? error.message : 'Failed to create admin.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user