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
+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>
))}