From 954994cdc01e5cee3e65153c2a302afed2da2b44 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 28 Mar 2026 17:15:05 -0400 Subject: [PATCH] fix(api-tokens): harden scope enforcement and add expiration support (#224) - Fix deploy-only allowlist to match actual routes (deploy, down, restart, stop, start, update) instead of non-existent /up, /pull, /compose/* paths - Block API tokens from auth-sensitive routes (password change, node token generation) that bypass scope enforcement middleware - Add WebSocket scope enforcement: read-only/deploy-only tokens can only access logs and notifications, not host console or container exec - Prevent API token self-replication: tokens cannot create, list, or revoke other tokens regardless of scope - Map deploy-only tokens to admin role so they pass requireAdmin on deploy routes (scope middleware still restricts which endpoints they can reach) - Add optional token expiration (30, 60, 90, 365 days or no expiry) - Add token name length validation (max 100 characters) - Surface fetchTokens errors in frontend instead of swallowing silently - Fix docs: correct deploy-only scope description and GitHub Actions example --- CHANGELOG.md | 16 ++--- backend/src/index.ts | 75 ++++++++++++++++++-- docs/features/api-tokens.mdx | 7 +- frontend/src/components/ApiTokensSection.tsx | 24 ++++++- 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bdd2eec..a1fa2a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +* **api-tokens:** harden scope enforcement — fix deploy-only allowlist to match actual routes, add WebSocket scope gating, block API tokens from auth-sensitive endpoints and token self-management, add configurable expiration support (30/60/90/365 days) + ## [0.14.0](https://github.com/AnsoCode/Sencho/compare/v0.13.2...v0.14.0) (2026-03-28) @@ -18,16 +24,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **license:** default 14-day trial to Personal Pro instead of Team Pro ([#216](https://github.com/AnsoCode/Sencho/issues/216)) ([f99abe9](https://github.com/AnsoCode/Sencho/commit/f99abe907d5a39f4f32fb08bf25eda9b00dae88b)) -## [Unreleased] - -### Added - -* **API Tokens / Service Accounts (Team Pro):** Generate scoped API tokens for CI/CD pipelines and automation. Three permission levels: read-only, deploy-only, and full-admin. Tokens are hashed at rest (SHA-256) and shown only once at creation. Scope enforcement at middleware level prevents tokens from exceeding their granted permissions. - -### Changed - -* 14-day trial now defaults to Personal Pro instead of Team Pro — Team Pro features (SSO, audit log, unlimited accounts) require a Team Pro license - ## [0.13.1](https://github.com/AnsoCode/Sencho/compare/v0.13.0...v0.13.1) (2026-03-28) diff --git a/backend/src/index.ts b/backend/src/index.ts index a18e0894..2d336a40 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -253,7 +253,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction): const creator = DatabaseService.getInstance().getUserById(apiToken.user_id); const roleMap: Record = { 'read-only': 'viewer', - 'deploy-only': 'viewer', + 'deploy-only': 'admin', 'full-admin': 'admin', }; req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer' }; @@ -395,6 +395,10 @@ app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response) // Update password endpoint - any authenticated user can change their own password app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response): Promise => { + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot change passwords.', code: 'SCOPE_DENIED' }); + return; + } try { const { oldPassword, newPassword } = req.body; if (!oldPassword || !newPassword) { @@ -448,6 +452,10 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void = // Generate a long-lived node proxy token for Sencho-to-Sencho authentication app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot generate node tokens.', code: 'SCOPE_DENIED' }); + return; + } try { const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; @@ -759,11 +767,12 @@ const requireAdmin = (req: Request, res: Response): boolean => { // Scope enforcement for API tokens — restricts which endpoints a token can reach. const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [ - /^\/api\/stacks\/[^/]+\/up$/, + /^\/api\/stacks\/[^/]+\/deploy$/, /^\/api\/stacks\/[^/]+\/down$/, /^\/api\/stacks\/[^/]+\/restart$/, - /^\/api\/stacks\/[^/]+\/pull$/, - /^\/api\/compose\/(up|down|start|stop|restart|pull)$/, + /^\/api\/stacks\/[^/]+\/stop$/, + /^\/api\/stacks\/[^/]+\/start$/, + /^\/api\/stacks\/[^/]+\/update$/, ]; const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => { @@ -1897,10 +1906,42 @@ server.on('upgrade', async (req, socket, head) => { // interactive terminal access (host console or container exec). const isProxyToken = decoded.scope === 'node_proxy'; + // API token scope enforcement for WebSocket connections + let wsApiTokenScope: string | null = null; + if (decoded.scope === 'api_token') { + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash); + if (!apiToken || apiToken.revoked_at) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + if (apiToken.expires_at && apiToken.expires_at < Date.now()) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id); + wsApiTokenScope = apiToken.scope; + } + const url = req.url || ''; const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname; + // Gate WebSocket paths by API token scope + if (wsApiTokenScope) { + const isLogPath = /^\/api\/stacks\/[^/]+\/logs$/.test(pathname); + const isNotifPath = pathname === '/ws/notifications'; + if (wsApiTokenScope === 'read-only' || wsApiTokenScope === 'deploy-only') { + if (!isLogPath && !isNotifPath) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } + } + } + // Resolve node context from query param const nodeIdParam = parsedUrl.searchParams.get('nodeId'); const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId(); @@ -3167,19 +3208,33 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise => // --- API Token Routes (Team Pro, admin-only, local-only) --- app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage other API tokens.', code: 'SCOPE_DENIED' }); + return; + } if (!requireAdmin(req, res)) return; if (!requireTeamPro(req, res)) return; try { - const { name, scope } = req.body; + const { name, scope, expires_in } = req.body; if (!name || typeof name !== 'string' || !name.trim()) { res.status(400).json({ error: 'Token name is required.' }); return; } + if (name.trim().length > 100) { + res.status(400).json({ error: 'Token name must be 100 characters or fewer.' }); + return; + } const validScopes = ['read-only', 'deploy-only', 'full-admin']; if (!scope || !validScopes.includes(scope)) { res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` }); return; } + const validExpiry = [30, 60, 90, 365]; + if (expires_in !== undefined && expires_in !== null && !validExpiry.includes(expires_in)) { + res.status(400).json({ error: `expires_in must be one of: ${validExpiry.join(', ')} (days), or null for no expiry.` }); + return; + } + const expiresAt = typeof expires_in === 'number' ? Date.now() + expires_in * 24 * 60 * 60 * 1000 : null; const settings = DatabaseService.getInstance().getGlobalSettings(); const jwtSecret = settings.auth_jwt_secret; @@ -3203,7 +3258,7 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): scope: scope as 'read-only' | 'deploy-only' | 'full-admin', user_id: user.id, created_at: Date.now(), - expires_at: null, + expires_at: expiresAt, }); res.status(201).json({ id, token: rawToken }); @@ -3214,6 +3269,10 @@ app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): }); app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise => { + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage other API tokens.', code: 'SCOPE_DENIED' }); + return; + } if (!requireAdmin(req, res)) return; if (!requireTeamPro(req, res)) return; try { @@ -3230,6 +3289,10 @@ app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): }); app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Response): Promise => { + if (req.apiTokenScope) { + res.status(403).json({ error: 'API tokens cannot manage other API tokens.', code: 'SCOPE_DENIED' }); + return; + } if (!requireAdmin(req, res)) return; if (!requireTeamPro(req, res)) return; try { diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index 6f54be68..622bcf1f 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -16,7 +16,7 @@ Every token is created with one of three permission levels: | Scope | Allowed actions | |-------|----------------| | **Read Only** | `GET` requests only — view stacks, containers, metrics, and settings | -| **Deploy Only** | Everything in Read Only, plus deploy-related actions (up, down, restart, pull) | +| **Deploy Only** | Everything in Read Only, plus stack operations: deploy, down, restart, stop, start, update | | **Full Admin** | Unrestricted access — equivalent to an admin user session | Choose the narrowest scope that fits your use case. A CI pipeline that only deploys stacks should use **Deploy Only**, not Full Admin. @@ -51,8 +51,7 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \ run: | curl -X POST \ -H "Authorization: Bearer ${{ secrets.SENCHO_TOKEN }}" \ - https://your-sencho-instance/api/compose/up \ - -d '{"stack": "my-app"}' + https://your-sencho-instance/api/stacks/my-app/deploy ``` @@ -75,5 +74,5 @@ Click the trash icon next to any token in the API Tokens settings tab. Revocatio - **Hashed storage** — Only a SHA-256 hash of the token is stored in the database. The raw token is never persisted. - **Audit trail** — All actions performed via API tokens are recorded in the [Audit Log](/features/audit-log) under the creating user's username. -- **No expiry by default** — Tokens do not expire automatically. Revoke tokens manually when they are no longer needed. +- **Optional expiry** — Tokens can be created with an expiration period (30 days, 60 days, 90 days, or 1 year). Tokens without an expiry must be revoked manually when no longer needed. - **Scope enforcement** — Permission checks happen at the middleware level before any route handler executes, ensuring consistent enforcement across all endpoints. diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 8badb091..d7336fa5 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -59,6 +59,7 @@ export function ApiTokensSection() { const [formName, setFormName] = useState(''); const [formScope, setFormScope] = useState('read-only'); + const [formExpiry, setFormExpiry] = useState(null); const fetchTokens = async () => { try { @@ -67,7 +68,7 @@ export function ApiTokensSection() { const data: ApiTokenListItem[] = await res.json(); setTokens(data.filter(t => !t.revoked_at)); } - } catch { /* ignore */ } finally { setLoading(false); } + } catch { toast.error('Failed to load API tokens.'); } finally { setLoading(false); } }; // eslint-disable-next-line react-hooks/set-state-in-effect @@ -83,7 +84,7 @@ export function ApiTokensSection() { const res = await apiFetch('/api-tokens', { method: 'POST', localOnly: true, - body: JSON.stringify({ name: formName.trim(), scope: formScope }), + body: JSON.stringify({ name: formName.trim(), scope: formScope, expires_in: formExpiry }), }); if (res.ok) { const data = await res.json(); @@ -91,6 +92,7 @@ export function ApiTokensSection() { setShowForm(false); setFormName(''); setFormScope('read-only'); + setFormExpiry(null); fetchTokens(); toast.success('API token created.'); } else { @@ -159,6 +161,19 @@ export function ApiTokensSection() { +
+ + +
))}