mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
feat(license): distributed license enforcement across multi-node setups (#359)
* feat(license): distributed license enforcement across multi-node setups The primary instance's license tier is now asserted to remote nodes on every proxied HTTP and WebSocket request via trusted headers. Remote nodes honor the assertion only when the request carries a valid node_proxy JWT, preventing unauthorized elevation from browsers or API tokens. Falls back to local license tier for direct access. * fix(test): remove unused vi import in distributed-license tests
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Tests for Distributed License Enforcement: the trust chain where the main
|
||||
* instance asserts its license tier to remote nodes via proxy headers.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
/** Helper: sign a token with the test JWT secret. */
|
||||
const signToken = (payload: Record<string, unknown>, expiresIn: string | number = '1m') =>
|
||||
jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: expiresIn as jwt.SignOptions['expiresIn'] });
|
||||
|
||||
// We need a Pro-gated route that doesn't depend on Docker or remote nodes.
|
||||
// /api/webhooks is Pro-gated and just reads from the DB — returns an empty array
|
||||
// if no webhooks exist.
|
||||
const PRO_ROUTE = '/api/webhooks';
|
||||
|
||||
// For Admiral routes, /api/audit-log is Admiral-gated and reads from the DB.
|
||||
const ADMIRAL_ROUTE = '/api/audit-log';
|
||||
|
||||
// ─── authMiddleware: proxyTier/proxyVariant propagation ─────────────────────
|
||||
|
||||
describe('authMiddleware - distributed license headers', () => {
|
||||
it('sets proxyTier/proxyVariant for node_proxy tokens with valid tier headers', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
// Hit a Pro-gated route with tier assertion - should be allowed
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
|
||||
// Should NOT get 403 PRO_REQUIRED — the proxy tier assertion grants access
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('ignores tier headers for user session tokens', async () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
// Even with tier headers set, a user session should use local license (community)
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
// Local license is community in test env → should get 403
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
});
|
||||
|
||||
it('ignores tier headers for malformed values on node_proxy tokens', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'enterprise') // invalid value
|
||||
.set('x-sencho-variant', 'mega'); // invalid value
|
||||
|
||||
// Invalid tier header → proxyTier not set → falls back to local (community) → 403
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
});
|
||||
|
||||
it('falls back to local tier when no tier headers on node_proxy token', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
// No tier headers → falls back to local (community) → 403
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requirePro guard ───────────────────────────────────────────────────────
|
||||
|
||||
describe('requirePro - distributed license', () => {
|
||||
it('allows access when proxy asserts pro tier', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', '');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks access when proxy asserts community tier', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks access for direct user when local tier is community', async () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── requireAdmiral guard ───────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmiral - distributed license', () => {
|
||||
it('allows access when proxy asserts pro tier with team variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts pro tier with personal variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', 'personal');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts community tier', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'community');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PRO_REQUIRED');
|
||||
});
|
||||
|
||||
it('blocks when proxy asserts pro tier with empty variant', async () => {
|
||||
const token = signToken({ scope: 'node_proxy' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', '');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security: header injection prevention ──────────────────────────────────
|
||||
|
||||
describe('Security - tier header injection', () => {
|
||||
it('cannot elevate access via tier headers on a user session', async () => {
|
||||
const token = signToken({ username: TEST_USERNAME, role: 'admin' });
|
||||
const res = await request(app)
|
||||
.get(ADMIRAL_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
// User session → tier headers ignored → local community tier → 403
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('cannot elevate access via tier headers without any auth', async () => {
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('x-sencho-tier', 'pro')
|
||||
.set('x-sencho-variant', 'team');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('cannot elevate access with expired node_proxy token', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '-1s' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('cannot elevate access with token signed by wrong secret', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, 'wrong-secret', { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.get(PRO_ROUTE)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('x-sencho-tier', 'pro');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
+48
-9
@@ -25,7 +25,7 @@ import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { LicenseService } from './services/LicenseService';
|
||||
import { LicenseService, type LicenseTier, type LicenseVariant, isLicenseTier, isLicenseVariant, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './services/LicenseService';
|
||||
import { WebhookService } from './services/WebhookService';
|
||||
import { SSOService } from './services/SSOService';
|
||||
import { SchedulerService } from './services/SchedulerService';
|
||||
@@ -237,6 +237,10 @@ declare global {
|
||||
nodeId: number;
|
||||
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
|
||||
rawBody?: Buffer;
|
||||
/** License tier asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyTier?: LicenseTier;
|
||||
/** License variant asserted by the main instance on proxied requests. Only set for trusted node_proxy tokens. */
|
||||
proxyVariant?: LicenseVariant;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,6 +302,23 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
|
||||
const dbUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
|
||||
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as UserRole) || 'admin', userId: dbUser?.id ?? 0 };
|
||||
|
||||
// Distributed License Enforcement: trust tier headers only from authenticated node proxy requests.
|
||||
// Browser sessions and API tokens cannot set these — only a valid node_proxy JWT (signed with
|
||||
// this instance's JWT secret) unlocks the trusted path.
|
||||
if (decoded.scope === 'node_proxy') {
|
||||
const tierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const variantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
||||
if (isLicenseTier(tierHeader)) {
|
||||
req.proxyTier = tierHeader;
|
||||
}
|
||||
if (isLicenseVariant(variantHeader)) {
|
||||
req.proxyVariant = variantHeader;
|
||||
} else if (variantHeader === '') {
|
||||
req.proxyVariant = null;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[Auth] Token validation failed:', (err as Error).message);
|
||||
@@ -818,8 +839,11 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
// --- License Routes (local-only, never proxied) ---
|
||||
|
||||
// Pro feature guard: returns false and sends 403 if not Pro tier.
|
||||
const requirePro = (_req: Request, res: Response): boolean => {
|
||||
if (LicenseService.getInstance().getTier() !== 'pro') {
|
||||
// Checks req.proxyTier first (set by authMiddleware for trusted node proxy requests),
|
||||
// falling back to the local LicenseService tier for direct access.
|
||||
const requirePro = (req: Request, res: Response): boolean => {
|
||||
const tier = req.proxyTier !== undefined ? req.proxyTier : LicenseService.getInstance().getTier();
|
||||
if (tier !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
@@ -827,13 +851,17 @@ const requirePro = (_req: Request, res: Response): boolean => {
|
||||
};
|
||||
|
||||
// Admiral feature guard: requires Pro tier with team variant.
|
||||
const requireAdmiral = (_req: Request, res: Response): boolean => {
|
||||
// Checks req.proxyTier/proxyVariant first (set by authMiddleware for trusted node proxy
|
||||
// requests), falling back to the local LicenseService for direct access.
|
||||
const requireAdmiral = (req: Request, res: Response): boolean => {
|
||||
const ls = LicenseService.getInstance();
|
||||
if (ls.getTier() !== 'pro') {
|
||||
const tier = req.proxyTier !== undefined ? req.proxyTier : ls.getTier();
|
||||
const variant = req.proxyVariant !== undefined ? req.proxyVariant : ls.getVariant();
|
||||
if (tier !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
if (ls.getVariant() !== 'team') {
|
||||
if (variant !== 'team') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Admiral.', code: 'ADMIRAL_REQUIRED' });
|
||||
return false;
|
||||
}
|
||||
@@ -849,9 +877,9 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
};
|
||||
|
||||
// Tier gate for scheduled tasks: 'update' action requires Pro, everything else requires Admiral.
|
||||
const requireScheduledTaskTier = (action: string, _req: Request, res: Response): boolean => {
|
||||
if (action === 'update') return requirePro(_req, res);
|
||||
return requireAdmiral(_req, res);
|
||||
const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (action === 'update') return requirePro(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
// --- Scoped RBAC Permission Engine (Admiral) ---
|
||||
@@ -2324,6 +2352,13 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
|
||||
if (node?.api_token) {
|
||||
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
|
||||
}
|
||||
// Distributed License Enforcement: assert the main instance's license tier to the
|
||||
// remote node so tier-gated routes honor the main's license instead of the node's local
|
||||
// (likely Community) tier. The remote's authMiddleware only trusts these headers when the
|
||||
// request carries a valid node_proxy JWT.
|
||||
const proxyLs = LicenseService.getInstance();
|
||||
proxyReq.setHeader(PROXY_TIER_HEADER, proxyLs.getTier());
|
||||
proxyReq.setHeader(PROXY_VARIANT_HEADER, proxyLs.getVariant() || '');
|
||||
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
|
||||
// doesn't reject the request with 404 ("Node X not found") - the remote
|
||||
// has no record of the gateway's node IDs and should treat the request
|
||||
@@ -2534,6 +2569,10 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
// Strip the browser's session cookie - it is signed by this instance's JWT secret and
|
||||
// would fail verification on the remote. Auth is handled exclusively via the Bearer token.
|
||||
delete req.headers['cookie'];
|
||||
// Distributed License Enforcement: assert the main's license tier on proxied WS connections.
|
||||
const wsLs = LicenseService.getInstance();
|
||||
req.headers[PROXY_TIER_HEADER] = wsLs.getTier();
|
||||
req.headers[PROXY_VARIANT_HEADER] = wsLs.getVariant() || '';
|
||||
// Strip nodeId from the forwarded URL so the remote treats the request as a local one.
|
||||
// The remote has no record of the gateway's nodeId, so leaving it would cause unnecessary
|
||||
// fallback logic. Removing it lets the remote default cleanly to its own local node.
|
||||
|
||||
@@ -7,6 +7,21 @@ export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disa
|
||||
|
||||
export type LicenseVariant = 'personal' | 'team' | null;
|
||||
|
||||
const VALID_TIERS: readonly string[] = ['community', 'pro'] satisfies readonly LicenseTier[];
|
||||
const VALID_VARIANTS: readonly string[] = ['personal', 'team'] satisfies readonly LicenseVariant[];
|
||||
|
||||
export function isLicenseTier(value: unknown): value is LicenseTier {
|
||||
return typeof value === 'string' && (VALID_TIERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isLicenseVariant(value: unknown): value is Exclude<LicenseVariant, null> {
|
||||
return typeof value === 'string' && (VALID_VARIANTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Header names used for Distributed License Enforcement between nodes. */
|
||||
export const PROXY_TIER_HEADER = 'x-sencho-tier';
|
||||
export const PROXY_VARIANT_HEADER = 'x-sencho-variant';
|
||||
|
||||
export interface LicenseInfo {
|
||||
tier: LicenseTier;
|
||||
status: LicenseStatus;
|
||||
|
||||
Reference in New Issue
Block a user