feat(gateway-clients): add policy-based gateway client tokens and gateway client route and DNS management endpoints
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-09 - 13.26.0 - feat(gateway-clients)
|
||||||
|
add policy-based gateway client tokens and gateway client route and DNS management endpoints
|
||||||
|
|
||||||
|
- Introduces API token policies with admin and gatewayClient roles, capability checks, hostname restrictions, and allowed route targets.
|
||||||
|
- Adds gateway client request and data interfaces for domains, DNS records, route sync, and ownership metadata while keeping workhoster aliases for compatibility.
|
||||||
|
- Extends route metadata normalization to prefer gatewayClient ownership and updates generated route names and test coverage accordingly.
|
||||||
|
|
||||||
## 2026-04-26 - 13.25.0 - feat(security)
|
## 2026-04-26 - 13.25.0 - feat(security)
|
||||||
compile network ranges and CIDR arrays into edge firewall policies
|
compile network ranges and CIDR arrays into edge firewall policies
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,16 @@ const makeApiTokenManager = (scopes: TScope[]) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
validateToken: async (rawToken: string) => rawToken === 'valid-token' ? token : null,
|
||||||
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => storedToken.scopes.includes(scope),
|
hasScope: (storedToken: interfaces.data.IStoredApiToken, scope: TScope) => {
|
||||||
|
const scopes = new Set(storedToken.scopes);
|
||||||
|
const compatibilityAliases: Partial<Record<TScope, TScope[]>> = {
|
||||||
|
'gateway-clients:read': ['workhosters:read'],
|
||||||
|
'gateway-clients:write': ['workhosters:write'],
|
||||||
|
'workhosters:read': ['gateway-clients:read'],
|
||||||
|
'workhosters:write': ['gateway-clients:write'],
|
||||||
|
};
|
||||||
|
return scopes.has(scope) || Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +141,9 @@ tap.test('WorkHosterHandler exposes capabilities and managed domains with workho
|
|||||||
dnsScopes: ['example.com'],
|
dnsScopes: ['example.com'],
|
||||||
http3: { enabled: false },
|
http3: { enabled: false },
|
||||||
},
|
},
|
||||||
routeConfigManager: {},
|
routeConfigManager: {
|
||||||
|
getMergedRoutes: () => ({ routes: [] }),
|
||||||
|
},
|
||||||
smartProxy: {},
|
smartProxy: {},
|
||||||
emailDomainManager: {},
|
emailDomainManager: {},
|
||||||
emailServer: {},
|
emailServer: {},
|
||||||
@@ -209,9 +220,12 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
|||||||
|
|
||||||
const createdRoute = routeConfig.routes.get('route-1')!;
|
const createdRoute = routeConfig.routes.get('route-1')!;
|
||||||
expect(createdRoute.createdBy).toEqual('token-user');
|
expect(createdRoute.createdBy).toEqual('token-user');
|
||||||
expect(createdRoute.route.name?.startsWith('workapp-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
||||||
expect(createdRoute.metadata).toEqual({
|
expect(createdRoute.metadata).toEqual({
|
||||||
ownerType: 'workhoster',
|
ownerType: 'gatewayClient',
|
||||||
|
gatewayClientType: 'onebox',
|
||||||
|
gatewayClientId: 'box-1',
|
||||||
|
gatewayClientAppId: 'app-1',
|
||||||
workHosterType: 'onebox',
|
workHosterType: 'onebox',
|
||||||
workHosterId: 'box-1',
|
workHosterId: 'box-1',
|
||||||
workAppId: 'app-1',
|
workAppId: 'app-1',
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.25.0',
|
version: '13.26.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
|
|||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { ApiTokenDoc } from '../db/index.js';
|
import { ApiTokenDoc } from '../db/index.js';
|
||||||
import type {
|
import type {
|
||||||
|
IApiTokenPolicy,
|
||||||
IStoredApiToken,
|
IStoredApiToken,
|
||||||
IApiTokenInfo,
|
IApiTokenInfo,
|
||||||
TApiTokenScope,
|
TApiTokenScope,
|
||||||
@@ -33,6 +34,7 @@ export class ApiTokenManager {
|
|||||||
scopes: TApiTokenScope[],
|
scopes: TApiTokenScope[],
|
||||||
expiresInDays: number | null,
|
expiresInDays: number | null,
|
||||||
createdBy: string,
|
createdBy: string,
|
||||||
|
policy?: IApiTokenPolicy,
|
||||||
): Promise<{ id: string; rawToken: string }> {
|
): Promise<{ id: string; rawToken: string }> {
|
||||||
const id = plugins.uuid.v4();
|
const id = plugins.uuid.v4();
|
||||||
const randomBytes = plugins.crypto.randomBytes(32);
|
const randomBytes = plugins.crypto.randomBytes(32);
|
||||||
@@ -47,6 +49,7 @@ export class ApiTokenManager {
|
|||||||
name,
|
name,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
scopes,
|
scopes,
|
||||||
|
policy,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null,
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
@@ -87,7 +90,31 @@ export class ApiTokenManager {
|
|||||||
* Check if a token has a specific scope.
|
* Check if a token has a specific scope.
|
||||||
*/
|
*/
|
||||||
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
public hasScope(token: IStoredApiToken, scope: TApiTokenScope): boolean {
|
||||||
return token.scopes.includes(scope);
|
if (token.policy?.role === 'admin') return true;
|
||||||
|
|
||||||
|
const isGatewayClientToken = token.policy?.role === 'gatewayClient';
|
||||||
|
const gatewayClientAllowedScopes = new Set<TApiTokenScope>([
|
||||||
|
'gateway-clients:read',
|
||||||
|
'gateway-clients:write',
|
||||||
|
'workhosters:read',
|
||||||
|
'workhosters:write',
|
||||||
|
]);
|
||||||
|
if (isGatewayClientToken && !gatewayClientAllowedScopes.has(scope)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isGatewayClientToken && token.scopes.includes('*')) return true;
|
||||||
|
|
||||||
|
const scopes = new Set<TApiTokenScope>([...token.scopes, ...(token.policy?.scopes || [])]);
|
||||||
|
if (scopes.has(scope)) return true;
|
||||||
|
|
||||||
|
const compatibilityAliases: Partial<Record<TApiTokenScope, TApiTokenScope[]>> = {
|
||||||
|
'gateway-clients:read': ['workhosters:read'],
|
||||||
|
'gateway-clients:write': ['workhosters:write'],
|
||||||
|
'workhosters:read': ['gateway-clients:read'],
|
||||||
|
'workhosters:write': ['gateway-clients:write'],
|
||||||
|
};
|
||||||
|
return Boolean(compatibilityAliases[scope]?.some((alias) => scopes.has(alias)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +127,7 @@ export class ApiTokenManager {
|
|||||||
id: stored.id,
|
id: stored.id,
|
||||||
name: stored.name,
|
name: stored.name,
|
||||||
scopes: stored.scopes,
|
scopes: stored.scopes,
|
||||||
|
policy: stored.policy,
|
||||||
createdAt: stored.createdAt,
|
createdAt: stored.createdAt,
|
||||||
expiresAt: stored.expiresAt,
|
expiresAt: stored.expiresAt,
|
||||||
lastUsedAt: stored.lastUsedAt,
|
lastUsedAt: stored.lastUsedAt,
|
||||||
@@ -165,6 +193,7 @@ export class ApiTokenManager {
|
|||||||
name: doc.name,
|
name: doc.name,
|
||||||
tokenHash: doc.tokenHash,
|
tokenHash: doc.tokenHash,
|
||||||
scopes: doc.scopes,
|
scopes: doc.scopes,
|
||||||
|
policy: doc.policy,
|
||||||
createdAt: doc.createdAt,
|
createdAt: doc.createdAt,
|
||||||
expiresAt: doc.expiresAt,
|
expiresAt: doc.expiresAt,
|
||||||
lastUsedAt: doc.lastUsedAt,
|
lastUsedAt: doc.lastUsedAt,
|
||||||
@@ -181,6 +210,7 @@ export class ApiTokenManager {
|
|||||||
existing.name = stored.name;
|
existing.name = stored.name;
|
||||||
existing.tokenHash = stored.tokenHash;
|
existing.tokenHash = stored.tokenHash;
|
||||||
existing.scopes = stored.scopes;
|
existing.scopes = stored.scopes;
|
||||||
|
existing.policy = stored.policy;
|
||||||
existing.createdAt = stored.createdAt;
|
existing.createdAt = stored.createdAt;
|
||||||
existing.expiresAt = stored.expiresAt;
|
existing.expiresAt = stored.expiresAt;
|
||||||
existing.lastUsedAt = stored.lastUsedAt;
|
existing.lastUsedAt = stored.lastUsedAt;
|
||||||
@@ -193,6 +223,7 @@ export class ApiTokenManager {
|
|||||||
doc.name = stored.name;
|
doc.name = stored.name;
|
||||||
doc.tokenHash = stored.tokenHash;
|
doc.tokenHash = stored.tokenHash;
|
||||||
doc.scopes = stored.scopes;
|
doc.scopes = stored.scopes;
|
||||||
|
doc.policy = stored.policy;
|
||||||
doc.createdAt = stored.createdAt;
|
doc.createdAt = stored.createdAt;
|
||||||
doc.expiresAt = stored.expiresAt;
|
doc.expiresAt = stored.expiresAt;
|
||||||
doc.lastUsedAt = stored.lastUsedAt;
|
doc.lastUsedAt = stored.lastUsedAt;
|
||||||
|
|||||||
@@ -452,14 +452,19 @@ export class RouteConfigManager {
|
|||||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||||
? metadata.lastResolvedAt
|
? metadata.lastResolvedAt
|
||||||
: undefined,
|
: undefined,
|
||||||
ownerType: metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
ownerType: metadata.ownerType === 'gatewayClient' || metadata.ownerType === 'workhoster' || metadata.ownerType === 'operator' || metadata.ownerType === 'system'
|
||||||
? metadata.ownerType
|
? metadata.ownerType
|
||||||
: undefined,
|
: undefined,
|
||||||
|
gatewayClientType: metadata.gatewayClientType === 'onebox' || metadata.gatewayClientType === 'cloudly' || metadata.gatewayClientType === 'custom'
|
||||||
|
? metadata.gatewayClientType
|
||||||
|
: metadata.workHosterType,
|
||||||
|
gatewayClientId: normalizeString(metadata.gatewayClientId || metadata.workHosterId),
|
||||||
|
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId || metadata.workAppId),
|
||||||
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
workHosterType: metadata.workHosterType === 'onebox' || metadata.workHosterType === 'cloudly' || metadata.workHosterType === 'custom'
|
||||||
? metadata.workHosterType
|
? metadata.workHosterType
|
||||||
: undefined,
|
: metadata.gatewayClientType,
|
||||||
workHosterId: normalizeString(metadata.workHosterId),
|
workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId),
|
||||||
workAppId: normalizeString(metadata.workAppId),
|
workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId),
|
||||||
externalKey: normalizeString(metadata.externalKey),
|
externalKey: normalizeString(metadata.externalKey),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -472,11 +477,19 @@ export class RouteConfigManager {
|
|||||||
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||||
normalized.lastResolvedAt = undefined;
|
normalized.lastResolvedAt = undefined;
|
||||||
}
|
}
|
||||||
if (normalized.ownerType !== 'workhoster') {
|
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||||
|
normalized.gatewayClientType = undefined;
|
||||||
|
normalized.gatewayClientId = undefined;
|
||||||
|
normalized.gatewayClientAppId = undefined;
|
||||||
normalized.workHosterType = undefined;
|
normalized.workHosterType = undefined;
|
||||||
normalized.workHosterId = undefined;
|
normalized.workHosterId = undefined;
|
||||||
normalized.workAppId = undefined;
|
normalized.workAppId = undefined;
|
||||||
normalized.externalKey = undefined;
|
normalized.externalKey = undefined;
|
||||||
|
} else {
|
||||||
|
normalized.ownerType = 'gatewayClient';
|
||||||
|
normalized.workHosterType = normalized.gatewayClientType;
|
||||||
|
normalized.workHosterId = normalized.gatewayClientId;
|
||||||
|
normalized.workAppId = normalized.gatewayClientAppId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.values(normalized).every((value) => value === undefined)) {
|
if (Object.values(normalized).every((value) => value === undefined)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../../plugins.js';
|
import * as plugins from '../../plugins.js';
|
||||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||||
import type { TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
import type { IApiTokenPolicy, TApiTokenScope } from '../../../ts_interfaces/data/route-management.js';
|
||||||
|
|
||||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||||
|
|
||||||
@@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc<ApiTokenDoc, A
|
|||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public scopes!: TApiTokenScope[];
|
public scopes!: TApiTokenScope[];
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public policy?: IApiTokenPolicy;
|
||||||
|
|
||||||
@plugins.smartdata.svDb()
|
@plugins.smartdata.svDb()
|
||||||
public createdAt!: number;
|
public createdAt!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class ApiTokenHandler {
|
|||||||
dataArg.scopes,
|
dataArg.scopes,
|
||||||
dataArg.expiresInDays ?? null,
|
dataArg.expiresInDays ?? null,
|
||||||
dataArg.identity.userId,
|
dataArg.identity.userId,
|
||||||
|
dataArg.policy,
|
||||||
);
|
);
|
||||||
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
return { success: true, tokenId: result.id, tokenValue: result.rawToken };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import * as plugins from '../../plugins.js';
|
|||||||
import type { OpsServer } from '../classes.opsserver.js';
|
import type { OpsServer } from '../classes.opsserver.js';
|
||||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||||
|
|
||||||
|
type TAuthContext = {
|
||||||
|
userId: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
token?: interfaces.data.IStoredApiToken;
|
||||||
|
};
|
||||||
|
|
||||||
export class WorkHosterHandler {
|
export class WorkHosterHandler {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
@@ -13,13 +19,13 @@ export class WorkHosterHandler {
|
|||||||
private async requireAuth(
|
private async requireAuth(
|
||||||
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
request: { identity?: interfaces.data.IIdentity; apiToken?: string },
|
||||||
requiredScope?: interfaces.data.TApiTokenScope,
|
requiredScope?: interfaces.data.TApiTokenScope,
|
||||||
): Promise<string> {
|
): Promise<TAuthContext> {
|
||||||
if (request.identity?.jwt) {
|
if (request.identity?.jwt) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({
|
||||||
identity: request.identity,
|
identity: request.identity,
|
||||||
});
|
});
|
||||||
if (isAdmin) return request.identity.userId;
|
if (isAdmin) return { userId: request.identity.userId, isAdmin: true };
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +35,7 @@ export class WorkHosterHandler {
|
|||||||
const token = await tokenManager.validateToken(request.apiToken);
|
const token = await tokenManager.validateToken(request.apiToken);
|
||||||
if (token) {
|
if (token) {
|
||||||
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
if (!requiredScope || tokenManager.hasScope(token, requiredScope)) {
|
||||||
return token.createdBy;
|
return { userId: token.createdBy, isAdmin: token.policy?.role === 'admin', token };
|
||||||
}
|
}
|
||||||
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
throw new plugins.typedrequest.TypedResponseError('insufficient scope');
|
||||||
}
|
}
|
||||||
@@ -44,35 +50,52 @@ export class WorkHosterHandler {
|
|||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayCapabilities>(
|
||||||
'getGatewayCapabilities',
|
'getGatewayCapabilities',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await this.requireAuth(dataArg, 'workhosters:read');
|
await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||||
return { capabilities: this.getGatewayCapabilities() };
|
return { capabilities: this.getGatewayCapabilities() };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDomains>(
|
||||||
|
'getGatewayClientDomains',
|
||||||
|
async (dataArg) => {
|
||||||
|
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||||
|
this.assertCapability(auth, 'readDomains');
|
||||||
|
return { domains: await this.listGatewayClientDomains(auth, dataArg.gatewayClientId) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetGatewayClientDnsRecords>(
|
||||||
|
'getGatewayClientDnsRecords',
|
||||||
|
async (dataArg) => {
|
||||||
|
const auth = await this.requireAuth(dataArg, 'gateway-clients:read');
|
||||||
|
this.assertCapability(auth, 'readDnsRecords');
|
||||||
|
return { records: await this.listGatewayClientDnsRecords(auth, dataArg.gatewayClientId) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWorkHosterDomains>(
|
||||||
'getWorkHosterDomains',
|
'getWorkHosterDomains',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
await this.requireAuth(dataArg, 'workhosters:read');
|
const auth = await this.requireAuth(dataArg, 'workhosters:read');
|
||||||
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
this.assertCapability(auth, 'readDomains');
|
||||||
if (!dnsManager) return { domains: [] };
|
return { domains: await this.listGatewayClientDomains(auth) };
|
||||||
|
|
||||||
const docs = await dnsManager.listDomains();
|
|
||||||
const domains = docs.map((domainDoc) => {
|
|
||||||
const domain = dnsManager.toPublicDomain(domainDoc);
|
|
||||||
const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
|
|
||||||
return {
|
|
||||||
...domain,
|
|
||||||
capabilities: {
|
|
||||||
canCreateSubdomains: canManageDnsRecords,
|
|
||||||
canManageDnsRecords,
|
|
||||||
canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
|
|
||||||
canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
|
|
||||||
},
|
},
|
||||||
} satisfies interfaces.data.IWorkHosterDomain;
|
),
|
||||||
});
|
);
|
||||||
return { domains };
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncGatewayClientRoute>(
|
||||||
|
'syncGatewayClientRoute',
|
||||||
|
async (dataArg) => {
|
||||||
|
const auth = await this.requireAuth(dataArg, 'gateway-clients:write');
|
||||||
|
this.assertCapability(auth, 'syncRoutes');
|
||||||
|
return await this.syncGatewayClientRoute(auth, dataArg.ownership, dataArg.route, dataArg.enabled, dataArg.delete);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -81,51 +104,15 @@ export class WorkHosterHandler {
|
|||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppRoute>(
|
||||||
'syncWorkAppRoute',
|
'syncWorkAppRoute',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const userId = await this.requireAuth(dataArg, 'workhosters:write');
|
const auth = await this.requireAuth(dataArg, 'workhosters:write');
|
||||||
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
this.assertCapability(auth, 'syncRoutes');
|
||||||
if (!manager) {
|
const ownership: interfaces.data.IGatewayClientOwnership = {
|
||||||
return { success: false, message: 'Route management not initialized' };
|
gatewayClientType: dataArg.ownership.workHosterType,
|
||||||
}
|
gatewayClientId: dataArg.ownership.workHosterId,
|
||||||
|
appId: dataArg.ownership.workAppId,
|
||||||
const externalKey = this.buildExternalKey(dataArg.ownership);
|
hostname: dataArg.ownership.hostname,
|
||||||
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
|
|
||||||
|
|
||||||
if (dataArg.delete) {
|
|
||||||
if (!existingRoute) {
|
|
||||||
return { success: true, action: 'unchanged' };
|
|
||||||
}
|
|
||||||
const result = await manager.deleteRoute(existingRoute.id);
|
|
||||||
return result.success
|
|
||||||
? { success: true, action: 'deleted', routeId: existingRoute.id }
|
|
||||||
: { success: false, message: result.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dataArg.route) {
|
|
||||||
return { success: false, message: 'route is required unless delete=true' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata: interfaces.data.IRouteMetadata = {
|
|
||||||
ownerType: 'workhoster',
|
|
||||||
workHosterType: dataArg.ownership.workHosterType,
|
|
||||||
workHosterId: dataArg.ownership.workHosterId,
|
|
||||||
workAppId: dataArg.ownership.workAppId,
|
|
||||||
externalKey,
|
|
||||||
};
|
};
|
||||||
const route = this.normalizeWorkAppRoute(dataArg.route, dataArg.ownership, externalKey);
|
return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete);
|
||||||
|
|
||||||
if (existingRoute) {
|
|
||||||
const result = await manager.updateRoute(existingRoute.id, {
|
|
||||||
route,
|
|
||||||
enabled: dataArg.enabled ?? true,
|
|
||||||
metadata,
|
|
||||||
});
|
|
||||||
return result.success
|
|
||||||
? { success: true, action: 'updated', routeId: existingRoute.id }
|
|
||||||
: { success: false, message: result.message };
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeId = await manager.createRoute(route, userId, dataArg.enabled ?? true, metadata);
|
|
||||||
return { success: true, action: 'created', routeId };
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -146,13 +133,13 @@ export class WorkHosterHandler {
|
|||||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_SyncWorkAppMailIdentity>(
|
||||||
'syncWorkAppMailIdentity',
|
'syncWorkAppMailIdentity',
|
||||||
async (dataArg) => {
|
async (dataArg) => {
|
||||||
const userId = await this.requireAuth(dataArg, 'workhosters:write');
|
const auth = await this.requireAuth(dataArg, 'workhosters:write');
|
||||||
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
const manager = this.opsServerRef.dcRouterRef.workAppMailManager;
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
return { success: false, message: 'WorkApp mail manager not initialized' };
|
return { success: false, message: 'WorkApp mail manager not initialized' };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await manager.syncMailIdentity(dataArg, userId);
|
return await manager.syncMailIdentity(dataArg, auth.userId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { success: false, message: (error as Error).message };
|
return { success: false, message: (error as Error).message };
|
||||||
}
|
}
|
||||||
@@ -205,6 +192,278 @@ export class WorkHosterHandler {
|
|||||||
].map((part) => part.trim()).join(':');
|
].map((part) => part.trim()).join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertCapability(
|
||||||
|
auth: TAuthContext,
|
||||||
|
capability: keyof NonNullable<interfaces.data.IApiTokenPolicy['capabilities']>,
|
||||||
|
): void {
|
||||||
|
if (auth.isAdmin) return;
|
||||||
|
const policy = auth.token?.policy;
|
||||||
|
if (!policy || policy.role !== 'gatewayClient') return;
|
||||||
|
if (policy.capabilities?.[capability] === true) return;
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`token capability missing: ${capability}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveGatewayClientId(auth: TAuthContext, requestedId?: string): string | undefined {
|
||||||
|
const policyClient = auth.token?.policy?.gatewayClient;
|
||||||
|
if (!policyClient) return requestedId;
|
||||||
|
if (requestedId && requestedId !== policyClient.id) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('gateway client token cannot access another gateway client');
|
||||||
|
}
|
||||||
|
return policyClient.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertGatewayClientOwnership(auth: TAuthContext, ownership: 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertRouteTargetsAllowed(auth: TAuthContext, route?: interfaces.data.IDcRouterRouteConfig): void {
|
||||||
|
const policy = auth.token?.policy;
|
||||||
|
if (!policy || policy.role !== 'gatewayClient' || !route) return;
|
||||||
|
const allowedTargets = policy.allowedRouteTargets || [];
|
||||||
|
if (allowedTargets.length === 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('gateway client token has no allowed route targets');
|
||||||
|
}
|
||||||
|
const targets = ((route.action as any)?.targets || []) as Array<{ host?: string; port?: number }>;
|
||||||
|
for (const target of targets) {
|
||||||
|
const host = String(target.host || '').trim().toLowerCase();
|
||||||
|
const port = Number(target.port);
|
||||||
|
const allowed = allowedTargets.some((allowedTarget) => {
|
||||||
|
return allowedTarget.host.trim().toLowerCase() === host && allowedTarget.ports.includes(port);
|
||||||
|
});
|
||||||
|
if (!allowed) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(`route target is outside token policy: ${host}:${port}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesHostnamePatterns(hostname: string, patterns: string[]): boolean {
|
||||||
|
const normalizedHostname = hostname.trim().toLowerCase();
|
||||||
|
if (!normalizedHostname) return false;
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const normalizedPattern = pattern.trim().toLowerCase();
|
||||||
|
if (!normalizedPattern) continue;
|
||||||
|
if (normalizedPattern === normalizedHostname) return true;
|
||||||
|
if (normalizedPattern.startsWith('*.')) {
|
||||||
|
const suffix = normalizedPattern.slice(2);
|
||||||
|
if (!normalizedHostname.endsWith(`.${suffix}`)) continue;
|
||||||
|
const prefix = normalizedHostname.slice(0, -(suffix.length + 1));
|
||||||
|
if (prefix && !prefix.includes('.')) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRouteHostnames(route: interfaces.data.IDcRouterRouteConfig): string[] {
|
||||||
|
const domains = (route.match as any)?.domains;
|
||||||
|
if (Array.isArray(domains)) {
|
||||||
|
return domains.map((domain) => String(domain).trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof domains === 'string') {
|
||||||
|
return domains.split(',').map((domain) => domain.trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOwnedRoutes(gatewayClientId?: string): interfaces.data.IMergedRoute[] {
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) return [];
|
||||||
|
return manager.getMergedRoutes().routes.filter((route) => {
|
||||||
|
const metadata = route.metadata;
|
||||||
|
if (!metadata) return false;
|
||||||
|
const ownerType = metadata.ownerType;
|
||||||
|
const isGatewayOwned = ownerType === 'gatewayClient' || ownerType === 'workhoster';
|
||||||
|
if (!isGatewayOwned) return false;
|
||||||
|
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId;
|
||||||
|
return gatewayClientId ? routeGatewayClientId === gatewayClientId : true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listGatewayClientDomains(
|
||||||
|
auth: TAuthContext,
|
||||||
|
requestedGatewayClientId?: string,
|
||||||
|
): Promise<interfaces.data.IGatewayClientDomain[]> {
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return [];
|
||||||
|
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
|
||||||
|
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
|
||||||
|
const routeHostnames = ownedRoutes.flatMap((route) => this.getRouteHostnames(route.route));
|
||||||
|
const docs = await dnsManager.listDomains();
|
||||||
|
|
||||||
|
return docs
|
||||||
|
.filter((domainDoc) => {
|
||||||
|
if (!auth.token?.policy || auth.token.policy.role !== 'gatewayClient') return true;
|
||||||
|
return routeHostnames.some((hostname) => this.isHostnameInDomain(hostname, domainDoc.name));
|
||||||
|
})
|
||||||
|
.map((domainDoc) => {
|
||||||
|
const domain = dnsManager.toPublicDomain(domainDoc);
|
||||||
|
const canManageDnsRecords = domain.source === 'dcrouter' || Boolean(domain.providerId);
|
||||||
|
const serviceCount = routeHostnames.filter((hostname) => this.isHostnameInDomain(hostname, domain.name)).length;
|
||||||
|
return {
|
||||||
|
...domain,
|
||||||
|
serviceCount,
|
||||||
|
managePath: `/domains/${domain.id}`,
|
||||||
|
capabilities: {
|
||||||
|
canCreateSubdomains: canManageDnsRecords,
|
||||||
|
canManageDnsRecords,
|
||||||
|
canIssueCertificates: Boolean(this.opsServerRef.dcRouterRef.smartProxy),
|
||||||
|
canHostEmail: Boolean(this.opsServerRef.dcRouterRef.emailDomainManager),
|
||||||
|
},
|
||||||
|
} satisfies interfaces.data.IGatewayClientDomain;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listGatewayClientDnsRecords(
|
||||||
|
auth: TAuthContext,
|
||||||
|
requestedGatewayClientId?: string,
|
||||||
|
): Promise<interfaces.data.IGatewayClientDnsRecord[]> {
|
||||||
|
const dnsManager = this.opsServerRef.dcRouterRef.dnsManager;
|
||||||
|
if (!dnsManager) return [];
|
||||||
|
const gatewayClientId = this.resolveGatewayClientId(auth, requestedGatewayClientId);
|
||||||
|
const ownedRoutes = this.getOwnedRoutes(gatewayClientId);
|
||||||
|
const domains = await dnsManager.listDomains();
|
||||||
|
const records: interfaces.data.IGatewayClientDnsRecord[] = [];
|
||||||
|
|
||||||
|
for (const route of ownedRoutes) {
|
||||||
|
const metadata = route.metadata;
|
||||||
|
if (!metadata) continue;
|
||||||
|
const gatewayClientType = metadata.gatewayClientType || metadata.workHosterType || 'custom';
|
||||||
|
const routeGatewayClientId = metadata.gatewayClientId || metadata.workHosterId || '';
|
||||||
|
const appId = metadata.gatewayClientAppId || metadata.workAppId || '';
|
||||||
|
|
||||||
|
for (const hostname of this.getRouteHostnames(route.route)) {
|
||||||
|
if (auth.token?.policy?.role === 'gatewayClient' && !this.matchesHostnamePatterns(hostname, auth.token.policy.hostnamePatterns || [])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const domainDoc = domains.find((domain) => this.isHostnameInDomain(hostname, domain.name));
|
||||||
|
const domainRecords = domainDoc ? await dnsManager.listRecordsForDomain(domainDoc.id) : [];
|
||||||
|
const matchingRecords = domainRecords.filter((record) => record.name === hostname);
|
||||||
|
if (matchingRecords.length === 0) {
|
||||||
|
records.push({
|
||||||
|
id: `missing:${hostname}`,
|
||||||
|
domainId: domainDoc?.id || '',
|
||||||
|
domainName: domainDoc?.name,
|
||||||
|
name: hostname,
|
||||||
|
type: 'MISSING',
|
||||||
|
value: '',
|
||||||
|
ttl: 0,
|
||||||
|
source: 'local',
|
||||||
|
status: 'missing',
|
||||||
|
gatewayClientType,
|
||||||
|
gatewayClientId: routeGatewayClientId,
|
||||||
|
appId,
|
||||||
|
hostname,
|
||||||
|
routeId: route.id,
|
||||||
|
managePath: domainDoc ? `/domains/${domainDoc.id}/dns` : '/domains',
|
||||||
|
createdAt: route.createdAt || 0,
|
||||||
|
updatedAt: route.updatedAt || 0,
|
||||||
|
createdBy: '',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const recordDoc of matchingRecords) {
|
||||||
|
const record = dnsManager.toPublicRecord(recordDoc);
|
||||||
|
records.push({
|
||||||
|
...record,
|
||||||
|
domainName: domainDoc?.name,
|
||||||
|
status: 'active',
|
||||||
|
gatewayClientType,
|
||||||
|
gatewayClientId: routeGatewayClientId,
|
||||||
|
appId,
|
||||||
|
hostname,
|
||||||
|
routeId: route.id,
|
||||||
|
managePath: `/dns-records/${record.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isHostnameInDomain(hostname: string, domainName: string): boolean {
|
||||||
|
const normalizedHostname = hostname.trim().toLowerCase();
|
||||||
|
const normalizedDomainName = domainName.trim().toLowerCase();
|
||||||
|
return normalizedHostname === normalizedDomainName || normalizedHostname.endsWith(`.${normalizedDomainName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncGatewayClientRoute(
|
||||||
|
auth: TAuthContext,
|
||||||
|
ownership: interfaces.data.IGatewayClientOwnership,
|
||||||
|
route?: interfaces.data.IDcRouterRouteConfig,
|
||||||
|
enabled?: boolean,
|
||||||
|
deleteRoute?: boolean,
|
||||||
|
): Promise<interfaces.data.IGatewayClientRouteSyncResult> {
|
||||||
|
this.assertGatewayClientOwnership(auth, ownership);
|
||||||
|
this.assertRouteTargetsAllowed(auth, route);
|
||||||
|
|
||||||
|
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
|
||||||
|
if (!manager) {
|
||||||
|
return { success: false, message: 'Route management not initialized' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalKey = this.buildGatewayClientExternalKey(ownership);
|
||||||
|
const existingRoute = manager.findApiRouteByExternalKey(externalKey);
|
||||||
|
|
||||||
|
if (deleteRoute) {
|
||||||
|
if (!existingRoute) {
|
||||||
|
return { success: true, action: 'unchanged' };
|
||||||
|
}
|
||||||
|
const result = await manager.deleteRoute(existingRoute.id);
|
||||||
|
return result.success
|
||||||
|
? { success: true, action: 'deleted', routeId: existingRoute.id }
|
||||||
|
: { success: false, message: result.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
return { success: false, message: 'route is required unless delete=true' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
externalKey,
|
||||||
|
};
|
||||||
|
const normalizedRoute = this.normalizeGatewayClientRoute(route, ownership, externalKey);
|
||||||
|
|
||||||
|
if (existingRoute) {
|
||||||
|
const result = await manager.updateRoute(existingRoute.id, {
|
||||||
|
route: normalizedRoute,
|
||||||
|
enabled: enabled ?? true,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
return result.success
|
||||||
|
? { success: true, action: 'updated', routeId: existingRoute.id }
|
||||||
|
: { success: false, message: result.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeId = await manager.createRoute(normalizedRoute, auth.userId, enabled ?? true, metadata);
|
||||||
|
return { success: true, action: 'created', routeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGatewayClientExternalKey(ownership: interfaces.data.IGatewayClientOwnership): string {
|
||||||
|
return [
|
||||||
|
ownership.gatewayClientType,
|
||||||
|
ownership.gatewayClientId,
|
||||||
|
ownership.appId,
|
||||||
|
ownership.hostname,
|
||||||
|
].map((part) => part.trim()).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeWorkAppRoute(
|
private normalizeWorkAppRoute(
|
||||||
route: interfaces.data.IDcRouterRouteConfig,
|
route: interfaces.data.IDcRouterRouteConfig,
|
||||||
ownership: interfaces.data.IWorkAppRouteOwnership,
|
ownership: interfaces.data.IWorkAppRouteOwnership,
|
||||||
@@ -216,4 +475,16 @@ export class WorkHosterHandler {
|
|||||||
}
|
}
|
||||||
return normalizedRoute;
|
return normalizedRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeGatewayClientRoute(
|
||||||
|
route: interfaces.data.IDcRouterRouteConfig,
|
||||||
|
ownership: interfaces.data.IGatewayClientOwnership,
|
||||||
|
externalKey: string,
|
||||||
|
): interfaces.data.IDcRouterRouteConfig {
|
||||||
|
const normalizedRoute = { ...route };
|
||||||
|
if (!normalizedRoute.name) {
|
||||||
|
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
|
||||||
|
}
|
||||||
|
return normalizedRoute;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type IRouteSecurity = NonNullable<IRouteConfig['security']>;
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type TApiTokenScope =
|
export type TApiTokenScope =
|
||||||
|
| '*'
|
||||||
| 'routes:read' | 'routes:write'
|
| 'routes:read' | 'routes:write'
|
||||||
| 'config:read'
|
| 'config:read'
|
||||||
| 'certificates:read' | 'certificates:write'
|
| 'certificates:read' | 'certificates:write'
|
||||||
@@ -21,9 +22,33 @@ export type TApiTokenScope =
|
|||||||
| 'dns-records:read' | 'dns-records:write'
|
| 'dns-records:read' | 'dns-records:write'
|
||||||
| 'acme-config:read' | 'acme-config:write'
|
| 'acme-config:read' | 'acme-config:write'
|
||||||
| 'email-domains:read' | 'email-domains:write'
|
| 'email-domains:read' | 'email-domains:write'
|
||||||
|
| 'gateway-clients:read' | 'gateway-clients:write'
|
||||||
| 'workhosters:read' | 'workhosters:write';
|
| 'workhosters:read' | 'workhosters:write';
|
||||||
|
|
||||||
export type TWorkHosterType = 'onebox' | 'cloudly' | 'custom';
|
export type TGatewayClientType = 'onebox' | 'cloudly' | 'custom';
|
||||||
|
/** @deprecated Use TGatewayClientType. */
|
||||||
|
export type TWorkHosterType = TGatewayClientType;
|
||||||
|
|
||||||
|
export interface IApiTokenPolicy {
|
||||||
|
role: 'admin' | 'gatewayClient' | 'operator';
|
||||||
|
scopes?: TApiTokenScope[];
|
||||||
|
gatewayClient?: {
|
||||||
|
type: TGatewayClientType;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
hostnamePatterns?: string[];
|
||||||
|
allowedRouteTargets?: Array<{
|
||||||
|
host: string;
|
||||||
|
ports: number[];
|
||||||
|
}>;
|
||||||
|
capabilities?: {
|
||||||
|
readDomains?: boolean;
|
||||||
|
readDnsRecords?: boolean;
|
||||||
|
syncRoutes?: boolean;
|
||||||
|
syncDnsRecords?: boolean;
|
||||||
|
requestCertificates?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Source Profile Types (source-side: who can access)
|
// Source Profile Types (source-side: who can access)
|
||||||
@@ -86,9 +111,15 @@ export interface IRouteMetadata {
|
|||||||
/** Timestamp of last reference resolution. */
|
/** Timestamp of last reference resolution. */
|
||||||
lastResolvedAt?: number;
|
lastResolvedAt?: number;
|
||||||
/** External route ownership, used by WorkHoster reconciliation. */
|
/** External route ownership, used by WorkHoster reconciliation. */
|
||||||
ownerType?: 'workhoster' | 'operator' | 'system';
|
ownerType?: 'gatewayClient' | 'workhoster' | 'operator' | 'system';
|
||||||
workHosterType?: TWorkHosterType;
|
gatewayClientType?: TGatewayClientType;
|
||||||
|
gatewayClientId?: string;
|
||||||
|
gatewayClientAppId?: string;
|
||||||
|
/** @deprecated Use gatewayClientType. */
|
||||||
|
workHosterType?: TGatewayClientType;
|
||||||
|
/** @deprecated Use gatewayClientId. */
|
||||||
workHosterId?: string;
|
workHosterId?: string;
|
||||||
|
/** @deprecated Use gatewayClientAppId. */
|
||||||
workAppId?: string;
|
workAppId?: string;
|
||||||
externalKey?: string;
|
externalKey?: string;
|
||||||
}
|
}
|
||||||
@@ -123,6 +154,7 @@ export interface IApiTokenInfo {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopes: TApiTokenScope[];
|
scopes: TApiTokenScope[];
|
||||||
|
policy?: IApiTokenPolicy;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
lastUsedAt: number | null;
|
lastUsedAt: number | null;
|
||||||
@@ -156,6 +188,7 @@ export interface IStoredApiToken {
|
|||||||
name: string;
|
name: string;
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
scopes: TApiTokenScope[];
|
scopes: TApiTokenScope[];
|
||||||
|
policy?: IApiTokenPolicy;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
lastUsedAt: number | null;
|
lastUsedAt: number | null;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { IDomain } from './domain.js';
|
import type { IDomain } from './domain.js';
|
||||||
|
import type { IDnsRecord, TDnsRecordType } from './dns-record.js';
|
||||||
|
import type { TGatewayClientType } from './route-management.js';
|
||||||
|
|
||||||
export interface IGatewayCapabilities {
|
export interface IGatewayCapabilities {
|
||||||
routes: {
|
routes: {
|
||||||
@@ -32,31 +34,66 @@ export interface IGatewayCapabilities {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkHosterDomain extends IDomain {
|
export interface IGatewayClientDomain extends IDomain {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
canCreateSubdomains: boolean;
|
canCreateSubdomains: boolean;
|
||||||
canManageDnsRecords: boolean;
|
canManageDnsRecords: boolean;
|
||||||
canIssueCertificates: boolean;
|
canIssueCertificates: boolean;
|
||||||
canHostEmail: boolean;
|
canHostEmail: boolean;
|
||||||
};
|
};
|
||||||
|
serviceCount?: number;
|
||||||
|
managePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use IGatewayClientDomain. */
|
||||||
|
export type IWorkHosterDomain = IGatewayClientDomain;
|
||||||
|
|
||||||
|
export interface IGatewayClientOwnership {
|
||||||
|
gatewayClientType: TGatewayClientType;
|
||||||
|
gatewayClientId: string;
|
||||||
|
appId: string;
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use IGatewayClientOwnership. */
|
||||||
export interface IWorkAppRouteOwnership {
|
export interface IWorkAppRouteOwnership {
|
||||||
workHosterType: 'onebox' | 'cloudly' | 'custom';
|
workHosterType: TGatewayClientType;
|
||||||
workHosterId: string;
|
workHosterId: string;
|
||||||
workAppId: string;
|
workAppId: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkAppRouteSyncResult {
|
export interface IGatewayClientRouteSyncResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
|
||||||
routeId?: string;
|
routeId?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use IGatewayClientRouteSyncResult. */
|
||||||
|
export type IWorkAppRouteSyncResult = IGatewayClientRouteSyncResult;
|
||||||
|
|
||||||
|
export interface IGatewayClientDnsRecord extends Omit<IDnsRecord, 'type'> {
|
||||||
|
type: TDnsRecordType | 'MISSING';
|
||||||
|
domainName?: string;
|
||||||
|
status: 'active' | 'missing';
|
||||||
|
gatewayClientType: TGatewayClientType;
|
||||||
|
gatewayClientId: string;
|
||||||
|
appId: string;
|
||||||
|
hostname: string;
|
||||||
|
routeId?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
managePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGatewayClientMailOwnership {
|
||||||
|
gatewayClientType: TGatewayClientType;
|
||||||
|
gatewayClientId: string;
|
||||||
|
appId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWorkAppMailOwnership {
|
export interface IWorkAppMailOwnership {
|
||||||
workHosterType: 'onebox' | 'cloudly' | 'custom';
|
workHosterType: TGatewayClientType;
|
||||||
workHosterId: string;
|
workHosterId: string;
|
||||||
workAppId: string;
|
workAppId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type * as authInterfaces from '../data/auth.js';
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
import type { IApiTokenInfo, TApiTokenScope } from '../data/route-management.js';
|
import type { IApiTokenInfo, IApiTokenPolicy, TApiTokenScope } from '../data/route-management.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Token Management Endpoints
|
// API Token Management Endpoints
|
||||||
@@ -19,6 +19,7 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl
|
|||||||
identity: authInterfaces.IIdentity;
|
identity: authInterfaces.IIdentity;
|
||||||
name: string;
|
name: string;
|
||||||
scopes: TApiTokenScope[];
|
scopes: TApiTokenScope[];
|
||||||
|
policy?: IApiTokenPolicy;
|
||||||
expiresInDays?: number | null;
|
expiresInDays?: number | null;
|
||||||
};
|
};
|
||||||
response: {
|
response: {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type * as authInterfaces from '../data/auth.js';
|
import type * as authInterfaces from '../data/auth.js';
|
||||||
import type {
|
import type {
|
||||||
|
IGatewayClientDnsRecord,
|
||||||
|
IGatewayClientDomain,
|
||||||
|
IGatewayClientOwnership,
|
||||||
|
IGatewayClientRouteSyncResult,
|
||||||
IGatewayCapabilities,
|
IGatewayCapabilities,
|
||||||
IWorkAppMailIdentity,
|
IWorkAppMailIdentity,
|
||||||
IWorkAppMailIdentitySyncResult,
|
IWorkAppMailIdentitySyncResult,
|
||||||
@@ -40,6 +44,36 @@ export interface IReq_GetWorkHosterDomains extends plugins.typedrequestInterface
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetGatewayClientDomains extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetGatewayClientDomains
|
||||||
|
> {
|
||||||
|
method: 'getGatewayClientDomains';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
gatewayClientId?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
domains: IGatewayClientDomain[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetGatewayClientDnsRecords extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetGatewayClientDnsRecords
|
||||||
|
> {
|
||||||
|
method: 'getGatewayClientDnsRecords';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
gatewayClientId?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
records: IGatewayClientDnsRecord[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_SyncWorkAppRoute
|
IReq_SyncWorkAppRoute
|
||||||
@@ -56,6 +90,22 @@ export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.im
|
|||||||
response: IWorkAppRouteSyncResult;
|
response: IWorkAppRouteSyncResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IReq_SyncGatewayClientRoute extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
|
IReq_SyncGatewayClientRoute
|
||||||
|
> {
|
||||||
|
method: 'syncGatewayClientRoute';
|
||||||
|
request: {
|
||||||
|
identity?: authInterfaces.IIdentity;
|
||||||
|
apiToken?: string;
|
||||||
|
ownership: IGatewayClientOwnership;
|
||||||
|
route?: IDcRouterRouteConfig;
|
||||||
|
enabled?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
};
|
||||||
|
response: IGatewayClientRouteSyncResult;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IReq_GetWorkAppMailIdentities extends plugins.typedrequestInterfaces.implementsTR<
|
export interface IReq_GetWorkAppMailIdentities extends plugins.typedrequestInterfaces.implementsTR<
|
||||||
plugins.typedrequestInterfaces.ITypedRequest,
|
plugins.typedrequestInterfaces.ITypedRequest,
|
||||||
IReq_GetWorkAppMailIdentities
|
IReq_GetWorkAppMailIdentities
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@serve.zone/dcrouter',
|
name: '@serve.zone/dcrouter',
|
||||||
version: '13.25.0',
|
version: '13.26.0',
|
||||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -2506,7 +2506,12 @@ export const fetchUsersAction = usersStatePart.createAction(async (statePartArg)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createApiToken(name: string, scopes: interfaces.data.TApiTokenScope[], expiresInDays?: number | null) {
|
export async function createApiToken(
|
||||||
|
name: string,
|
||||||
|
scopes: interfaces.data.TApiTokenScope[],
|
||||||
|
expiresInDays?: number | null,
|
||||||
|
policy?: any,
|
||||||
|
) {
|
||||||
const context = getActionContext();
|
const context = getActionContext();
|
||||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||||
interfaces.requests.IReq_CreateApiToken
|
interfaces.requests.IReq_CreateApiToken
|
||||||
@@ -2516,6 +2521,7 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT
|
|||||||
identity: context.identity!,
|
identity: context.identity!,
|
||||||
name,
|
name,
|
||||||
scopes,
|
scopes,
|
||||||
|
policy,
|
||||||
expiresInDays,
|
expiresInDays,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
const allScopes = [
|
const allScopes = [
|
||||||
|
'*',
|
||||||
'routes:read',
|
'routes:read',
|
||||||
'routes:write',
|
'routes:write',
|
||||||
'config:read',
|
'config:read',
|
||||||
@@ -213,6 +214,8 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
'dns-records:write',
|
'dns-records:write',
|
||||||
'email-domains:read',
|
'email-domains:read',
|
||||||
'email-domains:write',
|
'email-domains:write',
|
||||||
|
'gateway-clients:read',
|
||||||
|
'gateway-clients:write',
|
||||||
'workhosters:read',
|
'workhosters:read',
|
||||||
'workhosters:write',
|
'workhosters:write',
|
||||||
];
|
];
|
||||||
@@ -228,10 +231,15 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
<dees-input-tags
|
<dees-input-tags
|
||||||
.key=${'scopes'}
|
.key=${'scopes'}
|
||||||
.label=${'Token Scopes'}
|
.label=${'Token Scopes'}
|
||||||
.value=${['routes:read', 'routes:write']}
|
.value=${['gateway-clients:read', 'gateway-clients:write']}
|
||||||
.suggestions=${allScopes}
|
.suggestions=${allScopes}
|
||||||
.required=${true}
|
.required=${true}
|
||||||
></dees-input-tags>
|
></dees-input-tags>
|
||||||
|
<dees-input-text .key=${'policyRole'} .label=${'Policy Role'} .description=${'admin, gatewayClient, or operator'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'gatewayClientType'} .label=${'Gateway Client Type'} .description=${'For gatewayClient tokens: onebox, cloudly, or custom'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'gatewayClientId'} .label=${'Gateway Client ID'} .description=${'Required for gatewayClient tokens'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'hostnamePatterns'} .label=${'Hostname Patterns'} .description=${'Comma separated, e.g. *.apps.example.com'}></dees-input-text>
|
||||||
|
<dees-input-text .key=${'allowedRouteTarget'} .label=${'Allowed Route Target'} .description=${'Optional host:ports, e.g. 203.0.113.10:80,443'}></dees-input-text>
|
||||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
|
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
|
||||||
</dees-form>
|
</dees-form>
|
||||||
`,
|
`,
|
||||||
@@ -257,6 +265,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
|
const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || [];
|
||||||
const scopes = rawScopes
|
const scopes = rawScopes
|
||||||
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
.filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[];
|
||||||
|
const policy = this.buildPolicy(formData, scopes);
|
||||||
|
|
||||||
const expiresInDays = formData.expiresInDays
|
const expiresInDays = formData.expiresInDays
|
||||||
? parseInt(formData.expiresInDays, 10)
|
? parseInt(formData.expiresInDays, 10)
|
||||||
@@ -265,7 +274,7 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
await modalArg.destroy();
|
await modalArg.destroy();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays);
|
const response = await appstate.createApiToken(formData.name, scopes, expiresInDays, policy);
|
||||||
if (response.success && response.tokenValue) {
|
if (response.success && response.tokenValue) {
|
||||||
// Refresh the list first so it's ready when user dismisses the modal
|
// Refresh the list first so it's ready when user dismisses the modal
|
||||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchApiTokensAction, null);
|
||||||
@@ -299,6 +308,42 @@ export class OpsViewApiTokens extends DeesElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildPolicy(formData: any, scopes: TApiTokenScope[]): any | undefined {
|
||||||
|
const role = String(formData.policyRole || '').trim();
|
||||||
|
if (!role) return undefined;
|
||||||
|
const policy: any = {
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
};
|
||||||
|
if (role === 'gatewayClient') {
|
||||||
|
const type = String(formData.gatewayClientType || 'onebox').trim() as 'onebox' | 'cloudly' | 'custom';
|
||||||
|
const id = String(formData.gatewayClientId || '').trim();
|
||||||
|
if (id) {
|
||||||
|
policy.gatewayClient = { type, id };
|
||||||
|
}
|
||||||
|
policy.hostnamePatterns = String(formData.hostnamePatterns || '')
|
||||||
|
.split(',')
|
||||||
|
.map((pattern) => pattern.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const target = String(formData.allowedRouteTarget || '').trim();
|
||||||
|
if (target.includes(':')) {
|
||||||
|
const [host, portsValue] = target.split(':');
|
||||||
|
policy.allowedRouteTargets = [{
|
||||||
|
host: host.trim(),
|
||||||
|
ports: portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port)),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
policy.capabilities = {
|
||||||
|
readDomains: true,
|
||||||
|
readDnsRecords: true,
|
||||||
|
syncRoutes: true,
|
||||||
|
syncDnsRecords: false,
|
||||||
|
requestCertificates: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
|
private async showRollTokenDialog(token: interfaces.data.IApiTokenInfo) {
|
||||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user