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
This commit is contained in:
Anso
2026-03-28 17:15:05 -04:00
committed by GitHub
parent 88cd1fe571
commit 954994cdc0
4 changed files with 100 additions and 22 deletions
+6 -10
View File
@@ -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)
+69 -6
View File
@@ -253,7 +253,7 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
const roleMap: Record<string, 'admin' | 'viewer'> = {
'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<void> => {
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<void> => {
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<void> =>
// --- API Token Routes (Team Pro, admin-only, local-only) ---
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
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<void> => {
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<void> => {
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 {
+3 -4
View File
@@ -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
```
</CodeGroup>
@@ -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.
+22 -2
View File
@@ -59,6 +59,7 @@ export function ApiTokensSection() {
const [formName, setFormName] = useState('');
const [formScope, setFormScope] = useState('read-only');
const [formExpiry, setFormExpiry] = useState<number | null>(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() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Expiration</Label>
<Select value={formExpiry === null ? 'never' : String(formExpiry)} onValueChange={v => setFormExpiry(v === 'never' ? null : Number(v))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="30">30 days</SelectItem>
<SelectItem value="60">60 days</SelectItem>
<SelectItem value="90">90 days</SelectItem>
<SelectItem value="365">1 year</SelectItem>
<SelectItem value="never">No expiration</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleCreate} disabled={creating}>
@@ -243,6 +258,11 @@ export function ApiTokensSection() {
<span>
Last used: {token.last_used_at ? formatRelative(token.last_used_at) : 'Never'}
</span>
{token.expires_at && (
<span className={token.expires_at < Date.now() ? 'text-destructive' : ''}>
{token.expires_at < Date.now() ? 'Expired' : `Expires ${formatDate(token.expires_at)}`}
</span>
)}
</div>
</div>
))}