fix(opsserver,vpn): tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- tighten admin bootstrap behavior when the database is unavailable and include wildcard VPN profile matches in route access rules (opsserver,vpn)
|
||||||
|
- Block ephemeral admin bootstrap login and user listing until the configured database is ready, and report bootstrap availability accurately in admin status responses.
|
||||||
|
- Preserve persisted admin accounts across OpsServer restarts with added regression coverage.
|
||||||
|
- Merge matching VPN client IPs into restricted non-vpnOnly route allow lists without duplicating entries.
|
||||||
|
- Handle string and wildcard route domains consistently when resolving target profile access and VPN client matches.
|
||||||
|
|
||||||
## 2026-05-19 - 13.32.0
|
## 2026-05-19 - 13.32.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ let previousAdminPassword: string | undefined;
|
|||||||
let opsServer: OpsServer;
|
let opsServer: OpsServer;
|
||||||
let testDb: DcRouterDb;
|
let testDb: DcRouterDb;
|
||||||
let storagePath: string;
|
let storagePath: string;
|
||||||
|
let dbName: string;
|
||||||
let bootstrapIdentity: interfaces.data.IIdentity;
|
let bootstrapIdentity: interfaces.data.IIdentity;
|
||||||
let persistedIdentity: interfaces.data.IIdentity;
|
let persistedIdentity: interfaces.data.IIdentity;
|
||||||
let createdUserId: string;
|
let createdUserId: string;
|
||||||
@@ -28,26 +29,9 @@ const createLoginRequest = () => new TypedRequest<interfaces.requests.IReq_Admin
|
|||||||
'adminLoginWithUsernameAndPassword',
|
'adminLoginWithUsernameAndPassword',
|
||||||
);
|
);
|
||||||
|
|
||||||
tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
const createFakeDcRouter = (portArg: number, dcRouterDbArg?: DcRouterDb) => ({
|
||||||
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: {
|
options: {
|
||||||
opsServerPort: testPort,
|
opsServerPort: portArg,
|
||||||
dbConfig: { enabled: true },
|
dbConfig: { enabled: true },
|
||||||
adminAuth: {
|
adminAuth: {
|
||||||
idpClient: {
|
idpClient: {
|
||||||
@@ -70,10 +54,34 @@ tap.test('setup db-backed OpsServer admin bootstrap test', async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
typedrouter: new plugins.typedrequest.TypedRouter(),
|
typedrouter: new plugins.typedrequest.TypedRouter(),
|
||||||
dcRouterDb: testDb,
|
dcRouterDb: dcRouterDbArg,
|
||||||
};
|
});
|
||||||
|
|
||||||
opsServer = new OpsServer(fakeDcRouter as any);
|
const restartOpsServer = async () => {
|
||||||
|
await opsServer.stop();
|
||||||
|
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||||
|
await opsServer.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
dbName = `dcrouter-admin-bootstrap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
testDb = DcRouterDb.getInstance({
|
||||||
|
storagePath,
|
||||||
|
dbName,
|
||||||
|
});
|
||||||
|
await testDb.start();
|
||||||
|
await testDb.getDb().mongoDb.createCollection('__test_init');
|
||||||
|
|
||||||
|
opsServer = new OpsServer(createFakeDcRouter(testPort, testDb) as any);
|
||||||
await opsServer.start();
|
await opsServer.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,6 +178,30 @@ tap.test('authenticates the persisted admin locally by normalized email', async
|
|||||||
expect(response.identity.userId).toEqual(persistedIdentity.userId);
|
expect(response.identity.userId).toEqual(persistedIdentity.userId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('persists users across OpsServer restart', async () => {
|
||||||
|
const oldPersistedIdentity = persistedIdentity;
|
||||||
|
await restartOpsServer();
|
||||||
|
|
||||||
|
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||||
|
baseUrl,
|
||||||
|
'verifyIdentity',
|
||||||
|
);
|
||||||
|
const verifyResponse = await verifyRequest.fire({ identity: oldPersistedIdentity });
|
||||||
|
expect(verifyResponse.valid).toEqual(false);
|
||||||
|
|
||||||
|
const loginResponse = await createLoginRequest().fire({
|
||||||
|
username: 'admin@example.com',
|
||||||
|
password: persistedPassword,
|
||||||
|
authSource: 'local',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.identity) {
|
||||||
|
throw new Error('Expected persisted admin login identity after restart');
|
||||||
|
}
|
||||||
|
expect(loginResponse.identity.userId).toEqual(oldPersistedIdentity.userId);
|
||||||
|
persistedIdentity = loginResponse.identity;
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
|
tap.test('rejects idp.global login when IdP email does not match local account', async () => {
|
||||||
let rejected = false;
|
let rejected = false;
|
||||||
try {
|
try {
|
||||||
@@ -233,6 +265,28 @@ tap.test('lists persisted users without password material', async () => {
|
|||||||
expect((response.users[0] as any).password).toBeUndefined();
|
expect((response.users[0] as any).password).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('rejects temporary bootstrap admin when persisted-user database is unavailable', async () => {
|
||||||
|
await testDb.stop();
|
||||||
|
|
||||||
|
const status = await createStatusRequest().fire({});
|
||||||
|
expect(status.dbEnabled).toEqual(true);
|
||||||
|
expect(status.dbReady).toEqual(false);
|
||||||
|
expect(status.needsBootstrap).toEqual(false);
|
||||||
|
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||||
|
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await createLoginRequest().fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: bootstrapPassword,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rejected).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
||||||
await opsServer.stop();
|
await opsServer.stop();
|
||||||
await testDb.stop();
|
await testDb.stop();
|
||||||
@@ -246,4 +300,49 @@ tap.test('cleanup db-backed OpsServer admin bootstrap test', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('does not offer bootstrap while configured database is unavailable', async () => {
|
||||||
|
const unavailablePort = 3111;
|
||||||
|
const unavailableBaseUrl = `http://localhost:${unavailablePort}/typedrequest`;
|
||||||
|
const previousUnavailableAdminPassword = process.env.DCROUTER_ADMIN_PASSWORD;
|
||||||
|
process.env.DCROUTER_ADMIN_PASSWORD = 'unavailable-bootstrap-password';
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
|
||||||
|
const unavailableOpsServer = new OpsServer(createFakeDcRouter(unavailablePort) as any);
|
||||||
|
try {
|
||||||
|
await unavailableOpsServer.start();
|
||||||
|
const status = await new TypedRequest<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
||||||
|
unavailableBaseUrl,
|
||||||
|
'getAdminBootstrapStatus',
|
||||||
|
).fire({});
|
||||||
|
|
||||||
|
expect(status.dbEnabled).toEqual(true);
|
||||||
|
expect(status.dbReady).toEqual(false);
|
||||||
|
expect(status.needsBootstrap).toEqual(false);
|
||||||
|
expect(status.ephemeralAdminAvailable).toEqual(false);
|
||||||
|
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||||
|
unavailableBaseUrl,
|
||||||
|
'adminLoginWithUsernameAndPassword',
|
||||||
|
).fire({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'unavailable-bootstrap-password',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
rejected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rejected).toEqual(true);
|
||||||
|
} finally {
|
||||||
|
await unavailableOpsServer.stop();
|
||||||
|
DcRouterDb.resetInstance();
|
||||||
|
if (previousUnavailableAdminPassword === undefined) {
|
||||||
|
delete process.env.DCROUTER_ADMIN_PASSWORD;
|
||||||
|
} else {
|
||||||
|
process.env.DCROUTER_ADMIN_PASSWORD = previousUnavailableAdminPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|||||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||||
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
|
import { VpnManager } from '../ts/vpn/classes.vpn-manager.js';
|
||||||
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
|
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
|
||||||
|
import { TargetProfileManager } from '../ts/config/classes.target-profile-manager.js';
|
||||||
|
|
||||||
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
|
tap.test('VpnManager downgrades back to socket mode when no host-IP clients remain', async () => {
|
||||||
const manager = new VpnManager({ forwardingMode: 'socket' });
|
const manager = new VpnManager({ forwardingMode: 'socket' });
|
||||||
@@ -147,6 +148,85 @@ tap.test('RouteConfigManager replaces public allow lists for vpnOnly routes', as
|
|||||||
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('RouteConfigManager adds matching VPN clients to restricted non-vpnOnly routes', async () => {
|
||||||
|
const manager = new RouteConfigManager(
|
||||||
|
() => undefined,
|
||||||
|
undefined,
|
||||||
|
() => ['10.8.0.2'],
|
||||||
|
);
|
||||||
|
const route = {
|
||||||
|
name: 'shared-private-route',
|
||||||
|
match: { domains: ['app.example.com'] },
|
||||||
|
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] },
|
||||||
|
security: {
|
||||||
|
ipAllowList: ['203.0.113.10'],
|
||||||
|
ipBlockList: ['198.51.100.5'],
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const prepared = (manager as any).injectVpnSecurity(route);
|
||||||
|
|
||||||
|
expect(prepared.security.ipAllowList).toEqual(['203.0.113.10', '10.8.0.2']);
|
||||||
|
expect(prepared.security.ipBlockList).toEqual(['198.51.100.5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('TargetProfileManager matches wildcard profiles against string route domains', async () => {
|
||||||
|
const manager = new TargetProfileManager();
|
||||||
|
(manager as any).profiles.set('profile-1', {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'hagen.team VPN access',
|
||||||
|
domains: ['*.hagen.team'],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
createdBy: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = manager.getMatchingClientIps(
|
||||||
|
{
|
||||||
|
name: 'hagen-app',
|
||||||
|
match: { domains: 'app.hagen.team', ports: [443] },
|
||||||
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||||
|
} as any,
|
||||||
|
'route-1',
|
||||||
|
[{ enabled: true, assignedIp: '10.8.0.2', targetProfileIds: ['profile-1'] }] as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(entries).toEqual(['10.8.0.2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('TargetProfileManager expands wildcard profile domains to matching concrete route domains', async () => {
|
||||||
|
const manager = new TargetProfileManager();
|
||||||
|
(manager as any).profiles.set('profile-1', {
|
||||||
|
id: 'profile-1',
|
||||||
|
name: 'hagen.team VPN access',
|
||||||
|
domains: ['*.hagen.team'],
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
createdBy: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const routes = new Map([
|
||||||
|
['route-1', {
|
||||||
|
id: 'route-1',
|
||||||
|
enabled: true,
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
createdBy: 'test',
|
||||||
|
origin: 'api',
|
||||||
|
route: {
|
||||||
|
name: 'hagen-app',
|
||||||
|
match: { domains: 'app.hagen.team', ports: [443] },
|
||||||
|
action: { type: 'forward', targets: [{ host: '10.0.0.5', port: 443 }] },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
]) as any;
|
||||||
|
|
||||||
|
const accessSpec = manager.getClientAccessSpec(['profile-1'], routes);
|
||||||
|
|
||||||
|
expect(accessSpec.domains).toContain('*.hagen.team');
|
||||||
|
expect(accessSpec.domains).toContain('app.hagen.team');
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
|
tap.test('VpnManager rewrites WireGuard AllowedIPs after key rotation', async () => {
|
||||||
const manager = new VpnManager({
|
const manager = new VpnManager({
|
||||||
serverEndpoint: 'vpn.example.com',
|
serverEndpoint: 'vpn.example.com',
|
||||||
|
|||||||
@@ -608,9 +608,23 @@ export class RouteConfigManager {
|
|||||||
routeId?: string,
|
routeId?: string,
|
||||||
): plugins.smartproxy.IRouteConfig {
|
): plugins.smartproxy.IRouteConfig {
|
||||||
const dcRoute = route as IDcRouterRouteConfig;
|
const dcRoute = route as IDcRouterRouteConfig;
|
||||||
if (!dcRoute.vpnOnly) return route;
|
|
||||||
|
|
||||||
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
|
const vpnEntries = this.getVpnClientIpsForRoute?.(dcRoute, routeId) || [];
|
||||||
|
|
||||||
|
if (!dcRoute.vpnOnly) {
|
||||||
|
const existingAllowList = route.security?.ipAllowList;
|
||||||
|
if (!Array.isArray(existingAllowList) || existingAllowList.length === 0 || vpnEntries.length === 0) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
security: {
|
||||||
|
...route.security,
|
||||||
|
ipAllowList: this.mergeIpAllowEntries(existingAllowList as TIpAllowEntry[], vpnEntries),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const existingBlockList = route.security?.ipBlockList || [];
|
const existingBlockList = route.security?.ipBlockList || [];
|
||||||
const ipBlockList = vpnEntries.length
|
const ipBlockList = vpnEntries.length
|
||||||
? existingBlockList
|
? existingBlockList
|
||||||
@@ -625,4 +639,23 @@ export class RouteConfigManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mergeIpAllowEntries(
|
||||||
|
existingEntries: TIpAllowEntry[],
|
||||||
|
vpnEntries: TIpAllowEntry[],
|
||||||
|
): TIpAllowEntry[] {
|
||||||
|
const merged: TIpAllowEntry[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const entry of [...existingEntries, ...vpnEntries]) {
|
||||||
|
const key = typeof entry === 'string'
|
||||||
|
? `ip:${entry}`
|
||||||
|
: `domain:${entry.ip}:${[...entry.domains].sort().join(',')}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export class TargetProfileManager {
|
|||||||
allRoutes: Map<string, IRoute> = new Map(),
|
allRoutes: Map<string, IRoute> = new Map(),
|
||||||
): Array<string | { ip: string; domains: string[] }> {
|
): Array<string | { ip: string; domains: string[] }> {
|
||||||
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
const entries: Array<string | { ip: string; domains: string[] }> = [];
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains = this.getRouteDomains(route);
|
||||||
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
const routeNameIndex = this.buildRouteNameIndex(allRoutes);
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
@@ -298,15 +298,12 @@ export class TargetProfileManager {
|
|||||||
profile,
|
profile,
|
||||||
routeNameIndex,
|
routeNameIndex,
|
||||||
)) {
|
)) {
|
||||||
const routeDomains = (route.route.match as any)?.domains;
|
for (const d of this.getRouteDomains(route.route as IDcRouterRouteConfig)) {
|
||||||
if (Array.isArray(routeDomains)) {
|
|
||||||
for (const d of routeDomains) {
|
|
||||||
domains.add(d);
|
domains.add(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domains: [...domains],
|
domains: [...domains],
|
||||||
@@ -327,7 +324,7 @@ export class TargetProfileManager {
|
|||||||
profile: ITargetProfile,
|
profile: ITargetProfile,
|
||||||
routeNameIndex: Map<string, string[]>,
|
routeNameIndex: Map<string, string[]>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const routeDomains: string[] = (route.match as any)?.domains || [];
|
const routeDomains = this.getRouteDomains(route);
|
||||||
const result = this.routeMatchesProfileDetailed(
|
const result = this.routeMatchesProfileDetailed(
|
||||||
route,
|
route,
|
||||||
routeId,
|
routeId,
|
||||||
@@ -425,6 +422,12 @@ export class TargetProfileManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getRouteDomains(route: IDcRouterRouteConfig): string[] {
|
||||||
|
const domains = (route.match as any)?.domains;
|
||||||
|
if (!domains) return [];
|
||||||
|
return Array.isArray(domains) ? domains : [domains];
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
private normalizeRouteRefs(routeRefs?: string[]): string[] | undefined {
|
||||||
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
const allRoutes = this.getAllRoutes?.() || new Map<string, IRoute>();
|
||||||
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
return this.normalizeRouteRefsAgainstRoutes(routeRefs, allRoutes, 'strict');
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export class AdminHandler {
|
|||||||
// JWT instance
|
// JWT instance
|
||||||
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
||||||
|
|
||||||
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
|
// 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, {
|
private users = new Map<string, {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -87,9 +88,12 @@ export class AdminHandler {
|
|||||||
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
||||||
*/
|
*/
|
||||||
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
||||||
if (await this.hasPersistentAdminAccount()) {
|
const accountState = await this.getPersistentAccountState();
|
||||||
const store = this.getAccountStore();
|
if (accountState.dbEnabled && !accountState.dbReady) {
|
||||||
const accounts = await store!.listAccounts();
|
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 accounts.map((accountArg) => this.accountToUser(accountArg));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,16 +105,14 @@ export class AdminHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
||||||
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
const accountState = await this.getPersistentAccountState();
|
||||||
const store = this.getAccountStore();
|
const bootstrapAvailable = !accountState.dbEnabled || (accountState.dbReady && !accountState.hasPersistentAdmin);
|
||||||
const dbReady = !!store;
|
|
||||||
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
|
|
||||||
return {
|
return {
|
||||||
dbEnabled,
|
dbEnabled: accountState.dbEnabled,
|
||||||
dbReady,
|
dbReady: accountState.dbReady,
|
||||||
hasPersistentAdmin,
|
hasPersistentAdmin: accountState.hasPersistentAdmin,
|
||||||
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
|
needsBootstrap: accountState.dbEnabled && accountState.dbReady && !accountState.hasPersistentAdmin,
|
||||||
ephemeralAdminAvailable: !hasPersistentAdmin,
|
ephemeralAdminAvailable: bootstrapAvailable,
|
||||||
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -408,10 +410,14 @@ export class AdminHandler {
|
|||||||
password: string;
|
password: string;
|
||||||
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
||||||
}): Promise<TAdminUser | null> {
|
}): Promise<TAdminUser | null> {
|
||||||
if (await this.hasPersistentAdminAccount()) {
|
const accountState = await this.getPersistentAccountState();
|
||||||
const store = this.getAccountStore();
|
if (accountState.dbEnabled && !accountState.dbReady) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountState.hasPersistentAdmin) {
|
||||||
const authService = new plugins.idpSdkServer.AccountAuthService({
|
const authService = new plugins.idpSdkServer.AccountAuthService({
|
||||||
store: store!,
|
store: accountState.store!,
|
||||||
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
||||||
});
|
});
|
||||||
const result = await authService.authenticate({
|
const result = await authService.authenticate({
|
||||||
@@ -431,8 +437,13 @@ export class AdminHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
||||||
if (await this.hasPersistentAdminAccount()) {
|
const accountState = await this.getPersistentAccountState();
|
||||||
const account = await this.getAccountStore()!.getAccountById(userIdArg);
|
if (accountState.dbEnabled && !accountState.dbReady) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountState.hasPersistentAdmin) {
|
||||||
|
const account = await accountState.store!.getAccountById(userIdArg);
|
||||||
if (!account || account.status !== 'active') {
|
if (!account || account.status !== 'active') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -442,13 +453,25 @@ export class AdminHandler {
|
|||||||
return this.users.get(userIdArg) || null;
|
return this.users.get(userIdArg) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hasPersistentAdminAccount(): Promise<boolean> {
|
private async getPersistentAccountState(): Promise<{
|
||||||
const store = this.getAccountStore();
|
dbEnabled: boolean;
|
||||||
return store ? store.hasActiveAdminAccount() : false;
|
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 {
|
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
|
||||||
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
|
if (!this.isPersistenceEnabled()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
||||||
|
|||||||
Reference in New Issue
Block a user