feat(gateway-clients): add managed gateway client administration and token-bound route ownership

This commit is contained in:
2026-05-09 22:35:07 +00:00
parent d73b250382
commit 8dd0c3def9
22 changed files with 1287 additions and 48 deletions
@@ -307,6 +307,11 @@ export class CertificateHandler {
}
}
if (backoffInfo && status !== 'valid' && status !== 'expiring') {
status = 'failed';
error = error || backoffInfo.lastError;
}
certificates.push({
domain,
routeNames: info.routeNames,
+192 -18
View File
@@ -45,6 +45,16 @@ export class WorkHosterHandler {
throw new plugins.typedrequest.TypedResponseError('unauthorized');
}
private async requireAdmin(request: { identity?: interfaces.data.IIdentity }): Promise<string> {
if (request.identity?.jwt) {
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
identity: request.identity,
});
if (isAdmin) return request.identity.userId;
}
throw new plugins.typedrequest.TypedResponseError('admin identity required');
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
@@ -56,6 +66,122 @@ export class WorkHosterHandler {
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientContext>(
'getGatewayClientContext',
async (dataArg) => {
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
return {
context: this.getGatewayClientContext(auth),
capabilities: this.getGatewayCapabilities(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListGatewayClients>(
'listGatewayClients',
async (dataArg) => {
await this.requireAdmin(dataArg);
return { gatewayClients: await this.listManagedGatewayClients() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClient>(
'createGatewayClient',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
try {
const gatewayClient = await manager.createClient({
id: dataArg.id,
type: dataArg.type,
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
createdBy: userId,
});
return { success: true, gatewayClient };
} catch (error) {
return { success: false, message: (error as Error).message };
}
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateGatewayClient>(
'updateGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const gatewayClient = await manager.updateClient(dataArg.id, {
name: dataArg.name,
description: dataArg.description,
hostnamePatterns: dataArg.hostnamePatterns,
allowedRouteTargets: dataArg.allowedRouteTargets,
capabilities: dataArg.capabilities,
enabled: dataArg.enabled,
});
return gatewayClient
? { success: true, gatewayClient }
: { success: false, message: 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteGatewayClient>(
'deleteGatewayClient',
async (dataArg) => {
await this.requireAdmin(dataArg);
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return { success: false, message: 'Gateway client management not initialized' };
const success = await manager.deleteClient(dataArg.id);
return { success, message: success ? undefined : 'Gateway client not found' };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateGatewayClientToken>(
'createGatewayClientToken',
async (dataArg) => {
const userId = await this.requireAdmin(dataArg);
const gatewayClient = await this.opsServerRef.dcRouterRef.gatewayClientManager?.getClient(dataArg.gatewayClientId);
const tokenManager = this.opsServerRef.dcRouterRef.apiTokenManager;
if (!gatewayClient || !gatewayClient.enabled) {
return { success: false, message: 'Gateway client not found or disabled' };
}
if (!tokenManager) {
return { success: false, message: 'Token management not initialized' };
}
const result = await tokenManager.createToken(
dataArg.name?.trim() || `${gatewayClient.name} Token`,
['gateway-clients:read', 'gateway-clients:write'],
dataArg.expiresInDays ?? null,
userId,
{
role: 'gatewayClient',
scopes: ['gateway-clients:read', 'gateway-clients:write'],
gatewayClient: { type: gatewayClient.type, id: gatewayClient.id },
hostnamePatterns: gatewayClient.hostnamePatterns,
allowedRouteTargets: gatewayClient.allowedRouteTargets,
capabilities: gatewayClient.capabilities,
},
);
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
'getGatewayClientDomains',
@@ -183,6 +309,30 @@ export class WorkHosterHandler {
};
}
private getGatewayClientContext(auth: TAuthContext): interfaces.data.IGatewayClientContext {
const policy = auth.token?.policy;
const role = auth.isAdmin ? 'admin' : policy?.role || 'operator';
return {
role,
scopes: auth.token?.scopes || ['*'],
gatewayClient: policy?.gatewayClient,
hostnamePatterns: policy?.hostnamePatterns || [],
allowedRouteTargets: policy?.allowedRouteTargets || [],
capabilities: policy?.capabilities || {},
};
}
private async listManagedGatewayClients(): Promise<interfaces.data.IGatewayClient[]> {
const manager = this.opsServerRef.dcRouterRef.gatewayClientManager;
if (!manager) return [];
const clients = await manager.listClients();
const tokens = this.opsServerRef.dcRouterRef.apiTokenManager?.listTokens() || [];
return clients.map((client) => ({
...client,
tokenCount: tokens.filter((token) => token.policy?.gatewayClient?.id === client.id).length,
}));
}
private buildExternalKey(ownership: interfaces.data.IWorkAppRouteOwnership): string {
return [
ownership.workHosterType,
@@ -212,15 +362,38 @@ export class WorkHosterHandler {
return policyClient.id;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: interfaces.data.IGatewayClientOwnership): void {
private resolveGatewayClientOwnership(
auth: TAuthContext,
ownership: interfaces.data.IGatewayClientOwnership,
): Required<interfaces.data.IGatewayClientOwnership> {
const policy = auth.token?.policy;
if (policy?.role === 'gatewayClient') {
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType && ownership.gatewayClientType !== policy.gatewayClient.type) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (ownership.gatewayClientId && ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
return {
gatewayClientType: policy.gatewayClient.type,
gatewayClientId: policy.gatewayClient.id,
appId: ownership.appId,
hostname: ownership.hostname,
};
}
if (!ownership.gatewayClientType || !ownership.gatewayClientId) {
throw new plugins.typedrequest.TypedResponseError('gateway client ownership is missing type or id');
}
return ownership as Required<interfaces.data.IGatewayClientOwnership>;
}
private assertGatewayClientOwnership(auth: TAuthContext, ownership: Required<interfaces.data.IGatewayClientOwnership>): void {
const policy = auth.token?.policy;
if (!policy || policy.role !== 'gatewayClient') return;
if (!policy.gatewayClient) {
throw new plugins.typedrequest.TypedResponseError('gateway client token is missing gatewayClient binding');
}
if (ownership.gatewayClientType !== policy.gatewayClient.type || ownership.gatewayClientId !== policy.gatewayClient.id) {
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot act for this ownership');
}
if (!this.matchesHostnamePatterns(ownership.hostname, policy.hostnamePatterns || [])) {
throw new plugins.typedrequest.TypedResponseError('hostname is outside token policy');
}
@@ -403,7 +576,8 @@ export class WorkHosterHandler {
enabled?: boolean,
deleteRoute?: boolean,
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
this.assertGatewayClientOwnership(auth, ownership);
const resolvedOwnership = this.resolveGatewayClientOwnership(auth, ownership);
this.assertGatewayClientOwnership(auth, resolvedOwnership);
this.assertRouteTargetsAllowed(auth, route);
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
@@ -411,7 +585,7 @@ export class WorkHosterHandler {
return { success: false, message: 'Route management not initialized' };
}
const externalKey = this.buildGatewayClientExternalKey(ownership);
const externalKey = this.buildGatewayClientExternalKey(resolvedOwnership);
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
if (deleteRoute) {
@@ -430,15 +604,15 @@ export class WorkHosterHandler {
const metadata: interfaces.data.IRouteMetadata = {
ownerType: 'gatewayClient',
gatewayClientType: ownership.gatewayClientType,
gatewayClientId: ownership.gatewayClientId,
gatewayClientAppId: ownership.appId,
workHosterType: ownership.gatewayClientType,
workHosterId: ownership.gatewayClientId,
workAppId: ownership.appId,
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
gatewayClientAppId: resolvedOwnership.appId,
workHosterType: resolvedOwnership.gatewayClientType,
workHosterId: resolvedOwnership.gatewayClientId,
workAppId: resolvedOwnership.appId,
externalKey,
};
const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey);
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const result = await manager.updateRoute(existingRoute.id, {
@@ -455,7 +629,7 @@ export class WorkHosterHandler {
return { success: true, action: 'created', routeId };
}
private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string {
private buildGatewayClientExternalKey(ownership: Required<interfaces.data.IGatewayClientOwnership>): string {
return [
ownership.gatewayClientType,
ownership.gatewayClientId,
@@ -478,7 +652,7 @@ export class WorkHosterHandler {
private normalizeGatewayClientRoute(
route: interfaces.data.IDcRouterRouteConfig,
ownership: interfaces.data.IGatewayClientOwnership,
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };