mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +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:
@@ -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