mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
ecf4dd5d52
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
87 lines
3.9 KiB
TypeScript
87 lines
3.9 KiB
TypeScript
import type { Request, Response } from 'express';
|
|
import { LicenseService } from '../services/LicenseService';
|
|
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
|
|
|
// Tier-based route guards. Each returns true when the request may proceed and
|
|
// false after sending the appropriate 403 response. Callers MUST check the
|
|
// return value and `return;` on false.
|
|
//
|
|
// Guards trust req.proxyTier/proxyVariant (set by authMiddleware for
|
|
// node_proxy tokens) ahead of the local entitlement provider so a primary
|
|
// Sencho instance can assert license state for its remote fleet nodes.
|
|
|
|
const PAID_MESSAGE = 'This feature requires a Skipper or Admiral license.';
|
|
const ADMIRAL_MESSAGE = 'This feature requires a Sencho Admiral license.';
|
|
|
|
/** Effective license tier for this request (proxy header if trusted, else local). */
|
|
export const effectiveTier = (req: Request): LicenseTier =>
|
|
req.proxyTier ?? LicenseService.getInstance().getTier();
|
|
|
|
/** Effective license variant for this request (proxy header if trusted, else local). */
|
|
export const effectiveVariant = (req: Request): LicenseVariant =>
|
|
req.proxyVariant ?? LicenseService.getInstance().getVariant();
|
|
|
|
const deny = (res: Response, code: string, error: string): false => {
|
|
res.status(403).json({ error, code });
|
|
return false;
|
|
};
|
|
|
|
/** Paid feature guard: requires Skipper or Admiral. */
|
|
export const requirePaid = (req: Request, res: Response): boolean => {
|
|
if (effectiveTier(req) !== 'paid') return deny(res, 'PAID_REQUIRED', PAID_MESSAGE);
|
|
return true;
|
|
};
|
|
|
|
/** Admiral feature guard: requires paid tier with the admiral variant. */
|
|
export const requireAdmiral = (req: Request, res: Response): boolean => {
|
|
// Resolve both before branching so every caller observes the same
|
|
// tier/variant pair (the original behavior; tests mock LicenseService
|
|
// getters and rely on both being consumed per gate invocation).
|
|
const tier = effectiveTier(req);
|
|
const variant = effectiveVariant(req);
|
|
if (tier !== 'paid') return deny(res, 'PAID_REQUIRED', PAID_MESSAGE);
|
|
if (variant !== 'admiral') return deny(res, 'ADMIRAL_REQUIRED', ADMIRAL_MESSAGE);
|
|
return true;
|
|
};
|
|
|
|
/** Admin role guard: the request must be authenticated as an `admin` user. */
|
|
export const requireAdmin = (req: Request, res: Response): boolean => {
|
|
if (req.user?.role !== 'admin') return deny(res, 'ADMIN_REQUIRED', 'Admin access required.');
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Accept only calls from a sibling Sencho using its node_proxy Bearer token.
|
|
* Browser sessions, API tokens, and console tokens are all rejected.
|
|
*/
|
|
export const requireNodeProxy = (req: Request, res: Response): boolean => {
|
|
if (req.user?.username !== 'node-proxy') return deny(res, 'NODE_PROXY_REQUIRED', 'Node proxy authentication required.');
|
|
return true;
|
|
};
|
|
|
|
/** Tier gate for scheduled tasks: `update`, `scan`, and `snapshot` require Skipper+, everything else requires Admiral. */
|
|
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
|
if (action === 'update' || action === 'scan' || action === 'snapshot') return requirePaid(req, res);
|
|
return requireAdmiral(req, res);
|
|
};
|
|
|
|
/**
|
|
* Tier gate for SSO providers. The split is by delivery (turnkey vs self-configured), not by
|
|
* protocol: Custom OIDC stays free so self-hosters can wire any OIDC IdP (Authelia, Keycloak,
|
|
* Authentik, Zitadel); paid tiers get one-click presets and LDAP/AD.
|
|
*/
|
|
export const requireTierForSsoProvider = (provider: string, req: Request, res: Response): boolean => {
|
|
if (provider === 'oidc_custom') return true;
|
|
if (provider === 'ldap') return requireAdmiral(req, res);
|
|
return requirePaid(req, res);
|
|
};
|
|
|
|
/** 400s when the request has no object body. Used by endpoints that always expect JSON input. */
|
|
export const requireBody = (req: Request, res: Response): boolean => {
|
|
if (!req.body || typeof req.body !== 'object') {
|
|
res.status(400).json({ error: 'Request body is required' });
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|