security: API-Key Permissions + Customer-Scoping

- DB Migration: api_keys bekommt 'permissions' + 'customer_id' Spalten
- Permissions: agent | read | write | admin
  - agent:  nur /api/v1/agent/ws + /api/v1/agent/heartbeat
  - read:   GET-Endpoints (optional customer-scoped)
  - write:  alles ausser Terminal
  - admin:  voll (Terminal bleibt JWT-only)
- Bestehende Keys werden auf 'admin' migriert (Abwaertskompatibilitaet)
- Frontend: API-Key Verwaltung zeigt Permissions-Badge + Kunden-Scope
- API-Client: createAPIKey nimmt jetzt permissions + customer_id
This commit is contained in:
cynfo3000
2026-03-09 23:52:26 +01:00
parent 91385b42d1
commit ec30be7246
5 changed files with 238 additions and 55 deletions
+2 -2
View File
@@ -250,8 +250,8 @@ class ApiClient {
return this.get('/api/v1/apikeys')
}
createAPIKey(name) {
return this.post('/api/v1/apikeys', { name })
createAPIKey(name, permissions = 'read', customerId = null) {
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
}
deleteAPIKey(id) {
+79 -24
View File
@@ -199,18 +199,28 @@ export default function SettingsPage() {
)
}
const PERM_LABELS = {
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50', desc: 'Nur WS-Connect + Heartbeat' },
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50', desc: 'GET-Endpoints, kein Schreiben' },
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50', desc: 'Lesen + Schreiben, kein Terminal' },
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50', desc: 'Voll (kein Terminal)' },
}
function APIKeysSection() {
const [keys, setKeys] = useState([])
const [customers, setCustomers] = useState([])
const [showAdd, setShowAdd] = useState(false)
const [newName, setNewName] = useState('')
const [newPerms, setNewPerms] = useState('read')
const [newCustomer, setNewCustomer] = useState('')
const [newKey, setNewKey] = useState(null)
const [copied, setCopied] = useState(null)
const [loading, setLoading] = useState(true)
const reload = () => {
setLoading(true)
api.getAPIKeys()
.then(k => setKeys(k || []))
Promise.all([api.getAPIKeys(), api.getCustomers()])
.then(([k, c]) => { setKeys(k || []); setCustomers(c || []) })
.finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
@@ -218,9 +228,11 @@ function APIKeysSection() {
const handleCreate = async () => {
if (!newName.trim()) return
try {
const result = await api.createAPIKey(newName.trim())
const result = await api.createAPIKey(newName.trim(), newPerms, newCustomer ? parseInt(newCustomer) : null)
setNewKey(result)
setNewName('')
setNewPerms('read')
setNewCustomer('')
reload()
} catch (e) {
alert('Fehler: ' + e.message)
@@ -258,25 +270,56 @@ function APIKeysSection() {
{/* Neuer Key Dialog */}
{showAdd && !newKey && (
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500">Bezeichnung</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="z.B. Agent-Key Kunde XY"
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<div className="px-4 py-3 border-b border-gray-800 space-y-2">
<div className="flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500">Bezeichnung</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="z.B. Agent-Key Kunde XY"
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
</div>
<div className="w-36">
<label className="text-xs text-gray-500">Berechtigung</label>
<select
value={newPerms}
onChange={(e) => setNewPerms(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
>
<option value="agent">Agent (nur WS)</option>
<option value="read">Read-only</option>
<option value="write">Write</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="w-48">
<label className="text-xs text-gray-500">Kunde (optional)</label>
<select
value={newCustomer}
onChange={(e) => setNewCustomer(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
>
<option value="">Alle Kunden</option>
{customers.map(c => (
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
))}
</select>
</div>
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white self-end">
Erstellen
</button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 self-end">
Abbrechen
</button>
</div>
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
Erstellen
</button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
Abbrechen
</button>
{newPerms && PERM_LABELS[newPerms] && (
<div className="text-xs text-gray-500 pl-1">{PERM_LABELS[newPerms].desc}</div>
)}
</div>
)}
@@ -311,11 +354,22 @@ function APIKeysSection() {
<div className="px-4 py-6 text-center text-gray-500 text-sm">Keine API-Keys vorhanden</div>
) : (
<div className="divide-y divide-gray-800">
{keys.map((k) => (
{keys.map((k) => {
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
const customer = k.customer_id ? customers.find(c => c.id === k.customer_id) : null
return (
<div key={k.id} className="px-4 py-2.5 flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-white">{k.name}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none ${perm.color}`}>
{perm.label}
</span>
{customer && (
<span className="text-[10px] px-1.5 py-0.5 rounded border text-gray-400 bg-gray-800 border-gray-700 leading-none">
{customer.number}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-600 font-mono">{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}</span>
@@ -331,7 +385,7 @@ function APIKeysSection() {
</span>
{k.last_used && (
<span className="text-xs text-gray-600">
Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')}
Zuletzt: {new Date(k.last_used).toLocaleString('de-DE')}
</span>
)}
</div>
@@ -344,7 +398,8 @@ function APIKeysSection() {
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
)
})}
</div>
)}
</div>