mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 20:00:08 +00:00
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:
+6
-10
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user