From 97505935bb115355bc57a86136d479513e8fcbae Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 9 May 2026 11:53:45 +0000 Subject: [PATCH] feat(gateway-clients): add policy-based gateway client tokens and gateway client route and DNS management endpoints --- changelog.md | 7 + test/test.workhoster-handler.node.ts | 22 +- ts/00_commitinfo_data.ts | 2 +- ts/config/classes.api-token-manager.ts | 33 +- ts/config/classes.route-config-manager.ts | 23 +- ts/db/documents/classes.api-token.doc.ts | 5 +- ts/opsserver/handlers/api-token.handler.ts | 1 + ts/opsserver/handlers/workhoster.handler.ts | 407 +++++++++++++++---- ts_interfaces/data/route-management.ts | 39 +- ts_interfaces/data/workhoster.ts | 45 +- ts_interfaces/requests/api-tokens.ts | 3 +- ts_interfaces/requests/workhoster.ts | 50 +++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/appstate.ts | 8 +- ts_web/elements/access/ops-view-apitokens.ts | 49 ++- 15 files changed, 604 insertions(+), 92 deletions(-) diff --git a/changelog.md b/changelog.md index 9d5c611..c1a33dc 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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) compile network ranges and CIDR arrays into edge firewall policies diff --git a/test/test.workhoster-handler.node.ts b/test/test.workhoster-handler.node.ts index 8b51153..ee260f7 100644 --- a/test/test.workhoster-handler.node.ts +++ b/test/test.workhoster-handler.node.ts @@ -35,7 +35,16 @@ const makeApiTokenManager = (scopes: TScope[]) => { return { 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> = { + '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'], http3: { enabled: false }, }, - routeConfigManager: {}, + routeConfigManager: { + getMergedRoutes: () => ({ routes: [] }), + }, smartProxy: {}, emailDomainManager: {}, emailServer: {}, @@ -209,9 +220,12 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w const createdRoute = routeConfig.routes.get('route-1')!; 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({ - ownerType: 'workhoster', + ownerType: 'gatewayClient', + gatewayClientType: 'onebox', + gatewayClientId: 'box-1', + gatewayClientAppId: 'app-1', workHosterType: 'onebox', workHosterId: 'box-1', workAppId: 'app-1', diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f8c8c38..f5f958d 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.25.0', + version: '13.26.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/config/classes.api-token-manager.ts b/ts/config/classes.api-token-manager.ts index fd7068d..3f9077c 100644 --- a/ts/config/classes.api-token-manager.ts +++ b/ts/config/classes.api-token-manager.ts @@ -2,6 +2,7 @@ import * as plugins from '../plugins.js'; import { logger } from '../logger.js'; import { ApiTokenDoc } from '../db/index.js'; import type { + IApiTokenPolicy, IStoredApiToken, IApiTokenInfo, TApiTokenScope, @@ -33,6 +34,7 @@ export class ApiTokenManager { scopes: TApiTokenScope[], expiresInDays: number | null, createdBy: string, + policy?: IApiTokenPolicy, ): Promise<{ id: string; rawToken: string }> { const id = plugins.uuid.v4(); const randomBytes = plugins.crypto.randomBytes(32); @@ -47,6 +49,7 @@ export class ApiTokenManager { name, tokenHash, scopes, + policy, createdAt: now, expiresAt: expiresInDays != null ? now + expiresInDays * 86400000 : null, lastUsedAt: null, @@ -87,7 +90,31 @@ export class ApiTokenManager { * Check if a token has a specific scope. */ 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([ + '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([...token.scopes, ...(token.policy?.scopes || [])]); + if (scopes.has(scope)) return true; + + const compatibilityAliases: Partial> = { + '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, name: stored.name, scopes: stored.scopes, + policy: stored.policy, createdAt: stored.createdAt, expiresAt: stored.expiresAt, lastUsedAt: stored.lastUsedAt, @@ -165,6 +193,7 @@ export class ApiTokenManager { name: doc.name, tokenHash: doc.tokenHash, scopes: doc.scopes, + policy: doc.policy, createdAt: doc.createdAt, expiresAt: doc.expiresAt, lastUsedAt: doc.lastUsedAt, @@ -181,6 +210,7 @@ export class ApiTokenManager { existing.name = stored.name; existing.tokenHash = stored.tokenHash; existing.scopes = stored.scopes; + existing.policy = stored.policy; existing.createdAt = stored.createdAt; existing.expiresAt = stored.expiresAt; existing.lastUsedAt = stored.lastUsedAt; @@ -193,6 +223,7 @@ export class ApiTokenManager { doc.name = stored.name; doc.tokenHash = stored.tokenHash; doc.scopes = stored.scopes; + doc.policy = stored.policy; doc.createdAt = stored.createdAt; doc.expiresAt = stored.expiresAt; doc.lastUsedAt = stored.lastUsedAt; diff --git a/ts/config/classes.route-config-manager.ts b/ts/config/classes.route-config-manager.ts index 4a877fb..720c6c4 100644 --- a/ts/config/classes.route-config-manager.ts +++ b/ts/config/classes.route-config-manager.ts @@ -452,14 +452,19 @@ export class RouteConfigManager { lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt) ? metadata.lastResolvedAt : 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 : 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' ? metadata.workHosterType - : undefined, - workHosterId: normalizeString(metadata.workHosterId), - workAppId: normalizeString(metadata.workAppId), + : metadata.gatewayClientType, + workHosterId: normalizeString(metadata.workHosterId || metadata.gatewayClientId), + workAppId: normalizeString(metadata.workAppId || metadata.gatewayClientAppId), externalKey: normalizeString(metadata.externalKey), }; @@ -472,11 +477,19 @@ export class RouteConfigManager { if (!normalized.sourceProfileRef && !normalized.networkTargetRef) { 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.workHosterId = undefined; normalized.workAppId = 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)) { diff --git a/ts/db/documents/classes.api-token.doc.ts b/ts/db/documents/classes.api-token.doc.ts index 4f1e676..fc0e056 100644 --- a/ts/db/documents/classes.api-token.doc.ts +++ b/ts/db/documents/classes.api-token.doc.ts @@ -1,6 +1,6 @@ import * as plugins from '../../plugins.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(); @@ -19,6 +19,9 @@ export class ApiTokenDoc extends plugins.smartdata.SmartDataDbDoc { + ): Promise { if (request.identity?.jwt) { try { const isAdmin = await this.opsServerRef.adminHandler.adminIdentityGuard.exec({ identity: request.identity, }); - if (isAdmin) return request.identity.userId; + if (isAdmin) return { userId: request.identity.userId, isAdmin: true }; } catch { /* fall through */ } } @@ -29,7 +35,7 @@ export class WorkHosterHandler { const token = await tokenManager.validateToken(request.apiToken); if (token) { 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'); } @@ -44,35 +50,52 @@ export class WorkHosterHandler { new plugins.typedrequest.TypedHandler( 'getGatewayCapabilities', async (dataArg) => { - await this.requireAuth(dataArg, 'workhosters:read'); + await this.requireAuth(dataArg, 'gateway-clients:read'); return { capabilities: this.getGatewayCapabilities() }; }, ), ); + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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( + '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( new plugins.typedrequest.TypedHandler( 'getWorkHosterDomains', async (dataArg) => { - await this.requireAuth(dataArg, 'workhosters:read'); - const dnsManager = this.opsServerRef.dcRouterRef.dnsManager; - if (!dnsManager) return { domains: [] }; + const auth = await this.requireAuth(dataArg, 'workhosters:read'); + this.assertCapability(auth, 'readDomains'); + 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( + '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( 'syncWorkAppRoute', async (dataArg) => { - const userId = await this.requireAuth(dataArg, 'workhosters:write'); - const manager = this.opsServerRef.dcRouterRef.routeConfigManager; - if (!manager) { - return { success: false, message: 'Route management not initialized' }; - } - - const externalKey = this.buildExternalKey(dataArg.ownership); - 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 auth = await this.requireAuth(dataArg, 'workhosters:write'); + this.assertCapability(auth, 'syncRoutes'); + const ownership: interfaces.data.IGatewayClientOwnership = { + gatewayClientType: dataArg.ownership.workHosterType, + gatewayClientId: dataArg.ownership.workHosterId, + appId: dataArg.ownership.workAppId, + hostname: dataArg.ownership.hostname, }; - const route = this.normalizeWorkAppRoute(dataArg.route, dataArg.ownership, externalKey); - - 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 }; + return await this.syncGatewayClientRoute(auth, ownership, dataArg.route, dataArg.enabled, dataArg.delete); }, ), ); @@ -146,13 +133,13 @@ export class WorkHosterHandler { new plugins.typedrequest.TypedHandler( 'syncWorkAppMailIdentity', async (dataArg) => { - const userId = await this.requireAuth(dataArg, 'workhosters:write'); + const auth = await this.requireAuth(dataArg, 'workhosters:write'); const manager = this.opsServerRef.dcRouterRef.workAppMailManager; if (!manager) { return { success: false, message: 'WorkApp mail manager not initialized' }; } try { - return await manager.syncMailIdentity(dataArg, userId); + return await manager.syncMailIdentity(dataArg, auth.userId); } catch (error) { return { success: false, message: (error as Error).message }; } @@ -205,6 +192,278 @@ export class WorkHosterHandler { ].map((part) => part.trim()).join(':'); } + private assertCapability( + auth: TAuthContext, + capability: keyof NonNullable, + ): 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 { + 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 { + 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 { + 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( route: interfaces.data.IDcRouterRouteConfig, ownership: interfaces.data.IWorkAppRouteOwnership, @@ -216,4 +475,16 @@ export class WorkHosterHandler { } 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; + } } diff --git a/ts_interfaces/data/route-management.ts b/ts_interfaces/data/route-management.ts index b8d623e..9abd857 100644 --- a/ts_interfaces/data/route-management.ts +++ b/ts_interfaces/data/route-management.ts @@ -9,6 +9,7 @@ export type IRouteSecurity = NonNullable; // ============================================================================ export type TApiTokenScope = + | '*' | 'routes:read' | 'routes:write' | 'config:read' | 'certificates:read' | 'certificates:write' @@ -21,9 +22,33 @@ export type TApiTokenScope = | 'dns-records:read' | 'dns-records:write' | 'acme-config:read' | 'acme-config:write' | 'email-domains:read' | 'email-domains:write' + | 'gateway-clients:read' | 'gateway-clients: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) @@ -86,9 +111,15 @@ export interface IRouteMetadata { /** Timestamp of last reference resolution. */ lastResolvedAt?: number; /** External route ownership, used by WorkHoster reconciliation. */ - ownerType?: 'workhoster' | 'operator' | 'system'; - workHosterType?: TWorkHosterType; + ownerType?: 'gatewayClient' | 'workhoster' | 'operator' | 'system'; + gatewayClientType?: TGatewayClientType; + gatewayClientId?: string; + gatewayClientAppId?: string; + /** @deprecated Use gatewayClientType. */ + workHosterType?: TGatewayClientType; + /** @deprecated Use gatewayClientId. */ workHosterId?: string; + /** @deprecated Use gatewayClientAppId. */ workAppId?: string; externalKey?: string; } @@ -123,6 +154,7 @@ export interface IApiTokenInfo { id: string; name: string; scopes: TApiTokenScope[]; + policy?: IApiTokenPolicy; createdAt: number; expiresAt: number | null; lastUsedAt: number | null; @@ -156,6 +188,7 @@ export interface IStoredApiToken { name: string; tokenHash: string; scopes: TApiTokenScope[]; + policy?: IApiTokenPolicy; createdAt: number; expiresAt: number | null; lastUsedAt: number | null; diff --git a/ts_interfaces/data/workhoster.ts b/ts_interfaces/data/workhoster.ts index 90e1418..adfae29 100644 --- a/ts_interfaces/data/workhoster.ts +++ b/ts_interfaces/data/workhoster.ts @@ -1,4 +1,6 @@ import type { IDomain } from './domain.js'; +import type { IDnsRecord, TDnsRecordType } from './dns-record.js'; +import type { TGatewayClientType } from './route-management.js'; export interface IGatewayCapabilities { routes: { @@ -32,31 +34,66 @@ export interface IGatewayCapabilities { }; } -export interface IWorkHosterDomain extends IDomain { +export interface IGatewayClientDomain extends IDomain { capabilities: { canCreateSubdomains: boolean; canManageDnsRecords: boolean; canIssueCertificates: 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 { - workHosterType: 'onebox' | 'cloudly' | 'custom'; + workHosterType: TGatewayClientType; workHosterId: string; workAppId: string; hostname: string; } -export interface IWorkAppRouteSyncResult { +export interface IGatewayClientRouteSyncResult { success: boolean; action?: 'created' | 'updated' | 'deleted' | 'unchanged'; routeId?: string; message?: string; } +/** @deprecated Use IGatewayClientRouteSyncResult. */ +export type IWorkAppRouteSyncResult = IGatewayClientRouteSyncResult; + +export interface IGatewayClientDnsRecord extends Omit { + 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 { - workHosterType: 'onebox' | 'cloudly' | 'custom'; + workHosterType: TGatewayClientType; workHosterId: string; workAppId: string; } diff --git a/ts_interfaces/requests/api-tokens.ts b/ts_interfaces/requests/api-tokens.ts index 648692a..d054c9b 100644 --- a/ts_interfaces/requests/api-tokens.ts +++ b/ts_interfaces/requests/api-tokens.ts @@ -1,6 +1,6 @@ import * as plugins from '../plugins.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 @@ -19,6 +19,7 @@ export interface IReq_CreateApiToken extends plugins.typedrequestInterfaces.impl identity: authInterfaces.IIdentity; name: string; scopes: TApiTokenScope[]; + policy?: IApiTokenPolicy; expiresInDays?: number | null; }; response: { diff --git a/ts_interfaces/requests/workhoster.ts b/ts_interfaces/requests/workhoster.ts index 95dbfbc..fb4b3cb 100644 --- a/ts_interfaces/requests/workhoster.ts +++ b/ts_interfaces/requests/workhoster.ts @@ -1,6 +1,10 @@ import * as plugins from '../plugins.js'; import type * as authInterfaces from '../data/auth.js'; import type { + IGatewayClientDnsRecord, + IGatewayClientDomain, + IGatewayClientOwnership, + IGatewayClientRouteSyncResult, IGatewayCapabilities, IWorkAppMailIdentity, 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< plugins.typedrequestInterfaces.ITypedRequest, IReq_SyncWorkAppRoute @@ -56,6 +90,22 @@ export interface IReq_SyncWorkAppRoute extends plugins.typedrequestInterfaces.im 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< plugins.typedrequestInterfaces.ITypedRequest, IReq_GetWorkAppMailIdentities diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index f8c8c38..f5f958d 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.25.0', + version: '13.26.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 760669e..96a1888 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -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 request = new plugins.domtools.plugins.typedrequest.TypedRequest< interfaces.requests.IReq_CreateApiToken @@ -2516,6 +2521,7 @@ export async function createApiToken(name: string, scopes: interfaces.data.TApiT identity: context.identity!, name, scopes, + policy, expiresInDays, }); } diff --git a/ts_web/elements/access/ops-view-apitokens.ts b/ts_web/elements/access/ops-view-apitokens.ts index b3eb746..62bbc90 100644 --- a/ts_web/elements/access/ops-view-apitokens.ts +++ b/ts_web/elements/access/ops-view-apitokens.ts @@ -200,6 +200,7 @@ export class OpsViewApiTokens extends DeesElement { const { DeesModal } = await import('@design.estate/dees-catalog'); const allScopes = [ + '*', 'routes:read', 'routes:write', 'config:read', @@ -213,6 +214,8 @@ export class OpsViewApiTokens extends DeesElement { 'dns-records:write', 'email-domains:read', 'email-domains:write', + 'gateway-clients:read', + 'gateway-clients:write', 'workhosters:read', 'workhosters:write', ]; @@ -228,10 +231,15 @@ export class OpsViewApiTokens extends DeesElement { + + + + + `, @@ -257,6 +265,7 @@ export class OpsViewApiTokens extends DeesElement { const rawScopes: string[] = tagsInput?.getValue?.() || tagsInput?.value || formData.scopes || []; const scopes = rawScopes .filter((s: string) => allScopes.includes(s as any)) as TApiTokenScope[]; + const policy = this.buildPolicy(formData, scopes); const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) @@ -265,7 +274,7 @@ export class OpsViewApiTokens extends DeesElement { await modalArg.destroy(); 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) { // Refresh the list first so it's ready when user dismisses the modal 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) { const { DeesModal } = await import('@design.estate/dees-catalog');