feat(gateway-clients): add managed gateway client administration and token-bound route ownership
This commit is contained in:
@@ -21,7 +21,10 @@ const fireTypedRequest = async (
|
||||
} as any, { localRequest: true, skipHooks: true }) as any;
|
||||
};
|
||||
|
||||
const makeApiTokenManager = (scopes: TScope[]) => {
|
||||
const makeApiTokenManager = (
|
||||
scopes: TScope[],
|
||||
policy?: interfaces.data.IApiTokenPolicy,
|
||||
) => {
|
||||
const token = {
|
||||
id: 'token-1',
|
||||
name: 'workhoster-test-token',
|
||||
@@ -31,12 +34,26 @@ const makeApiTokenManager = (scopes: TScope[]) => {
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
policy,
|
||||
} as interfaces.data.IStoredApiToken;
|
||||
|
||||
return {
|
||||
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
||||
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
|
||||
if (storedToken.policy?.role === 'admin') return true;
|
||||
const isGatewayClientToken = storedToken.policy?.role === 'gatewayClient';
|
||||
const gatewayClientAllowedScopes = new Set<TScope>([
|
||||
'gateway-clients:read',
|
||||
'gateway-clients:write',
|
||||
'workhosters:read',
|
||||
'workhosters:write',
|
||||
]);
|
||||
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) return false;
|
||||
if (!isGatewayClientToken && storedToken.scopes.includes('*')) return true;
|
||||
const scopes = new Set(storedToken.scopes);
|
||||
for (const policyScope of storedToken.policy?.scopes || []) {
|
||||
scopes.add(policyScope);
|
||||
}
|
||||
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
|
||||
'gateway-clients:read': ['workhosters:read'],
|
||||
'gateway-clients:write': ['workhosters:write'],
|
||||
@@ -111,6 +128,8 @@ const makeRouteConfigManager = () => {
|
||||
|
||||
const setupHandler = (options: {
|
||||
scopes: TScope[];
|
||||
policy?: interfaces.data.IApiTokenPolicy;
|
||||
isAdmin?: boolean;
|
||||
dcRouterRef?: Record<string, any>;
|
||||
}) => {
|
||||
const typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -118,12 +137,12 @@ const setupHandler = (options: {
|
||||
typedrouter,
|
||||
adminHandler: {
|
||||
adminIdentityGuard: {
|
||||
exec: async () => false,
|
||||
exec: async () => Boolean(options.isAdmin),
|
||||
},
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
apiTokenManager: makeApiTokenManager(options.scopes),
|
||||
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
|
||||
...options.dcRouterRef,
|
||||
},
|
||||
};
|
||||
@@ -274,6 +293,153 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(unchangedResult.response).toEqual({ success: true, action: 'unchanged' });
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler exposes gateway client context for token-bound clients', async () => {
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:read'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
hostnamePatterns: ['*.example.com'],
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
||||
capabilities: {
|
||||
readDomains: true,
|
||||
readDnsRecords: true,
|
||||
syncRoutes: true,
|
||||
},
|
||||
},
|
||||
dcRouterRef: { options: {} },
|
||||
});
|
||||
|
||||
const result = await fireTypedRequest(typedrouter, 'getGatewayClientContext', {
|
||||
apiToken: 'valid-token',
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.response.context.gatewayClient).toEqual({ type: 'onebox', id: 'box-policy' });
|
||||
expect(result.response.context.hostnamePatterns).toEqual(['*.example.com']);
|
||||
expect(result.response.context.capabilities.syncRoutes).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler derives route ownership from gateway client token policy', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: ['gateway-clients:write'],
|
||||
policy: {
|
||||
role: 'gatewayClient',
|
||||
gatewayClient: { type: 'onebox', id: 'box-policy' },
|
||||
hostnamePatterns: ['*.example.com'],
|
||||
allowedRouteTargets: [{ host: '10.0.0.2', ports: [8080] }],
|
||||
capabilities: { syncRoutes: true },
|
||||
},
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
routeConfigManager: routeConfig.manager,
|
||||
},
|
||||
});
|
||||
|
||||
const createResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
appId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
route: {
|
||||
match: { ports: [443], domains: ['app.example.com'] },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '10.0.0.2', port: 8080 }],
|
||||
tls: { mode: 'terminate', certificate: 'auto' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResult.error).toBeUndefined();
|
||||
expect(createResult.response).toEqual({ success: true, action: 'created', routeId: 'route-1' });
|
||||
expect(routeConfig.routes.get('route-1')?.metadata?.gatewayClientId).toEqual('box-policy');
|
||||
expect(routeConfig.routes.get('route-1')?.metadata?.externalKey).toEqual('onebox:box-policy:app-1:app.example.com');
|
||||
|
||||
const spoofResult = await fireTypedRequest(typedrouter, 'syncGatewayClientRoute', {
|
||||
apiToken: 'valid-token',
|
||||
ownership: {
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'other-box',
|
||||
appId: 'app-1',
|
||||
hostname: 'app.example.com',
|
||||
},
|
||||
delete: true,
|
||||
});
|
||||
|
||||
expect(spoofResult.error?.text).toEqual('gateway client token cannot act for this ownership');
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler manages durable gateway clients and creates scoped tokens', async () => {
|
||||
const identity: interfaces.data.IIdentity = {
|
||||
jwt: 'admin-jwt',
|
||||
userId: 'admin-user',
|
||||
name: 'admin',
|
||||
expiresAt: Date.now() + 3600000,
|
||||
};
|
||||
const gatewayClient: interfaces.data.IGatewayClient = {
|
||||
id: 'onebox-main',
|
||||
type: 'onebox',
|
||||
name: 'Main Onebox',
|
||||
hostnamePatterns: ['*.apps.example.com'],
|
||||
allowedRouteTargets: [{ host: 'onebox-smartproxy', ports: [80] }],
|
||||
capabilities: { readDomains: true, readDnsRecords: true, syncRoutes: true },
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'admin-user',
|
||||
};
|
||||
let createdTokenPolicy: interfaces.data.IApiTokenPolicy | undefined;
|
||||
const { typedrouter } = setupHandler({
|
||||
scopes: [],
|
||||
isAdmin: true,
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
gatewayClientManager: {
|
||||
listClients: async () => [gatewayClient],
|
||||
getClient: async (id: string) => id === gatewayClient.id ? gatewayClient : null,
|
||||
},
|
||||
apiTokenManager: {
|
||||
listTokens: () => [{
|
||||
id: 'token-1',
|
||||
name: 'token',
|
||||
scopes: ['gateway-clients:read'],
|
||||
policy: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-main' } },
|
||||
createdAt: 1,
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
enabled: true,
|
||||
}],
|
||||
createToken: async (
|
||||
_name: string,
|
||||
_scopes: TScope[],
|
||||
_expiresInDays: number | null,
|
||||
_createdBy: string,
|
||||
policy?: interfaces.data.IApiTokenPolicy,
|
||||
) => {
|
||||
createdTokenPolicy = policy;
|
||||
return { id: 'new-token', rawToken: 'dcr_created' };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await fireTypedRequest(typedrouter, 'listGatewayClients', { identity });
|
||||
expect(listResult.error).toBeUndefined();
|
||||
expect(listResult.response.gatewayClients[0].tokenCount).toEqual(1);
|
||||
|
||||
const tokenResult = await fireTypedRequest(typedrouter, 'createGatewayClientToken', {
|
||||
identity,
|
||||
gatewayClientId: 'onebox-main',
|
||||
});
|
||||
expect(tokenResult.error).toBeUndefined();
|
||||
expect(tokenResult.response.tokenValue).toEqual('dcr_created');
|
||||
expect(createdTokenPolicy?.gatewayClient).toEqual({ type: 'onebox', id: 'onebox-main' });
|
||||
expect(createdTokenPolicy?.allowedRouteTargets).toEqual([{ host: 'onebox-smartproxy', ports: [80] }]);
|
||||
});
|
||||
|
||||
tap.test('WorkHosterHandler rejects WorkApp route sync without workhosters:write', async () => {
|
||||
const routeConfig = makeRouteConfigManager();
|
||||
const { typedrouter } = setupHandler({
|
||||
|
||||
Reference in New Issue
Block a user