feat(gateway-clients): add managed gateway client administration and token-bound route ownership
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user