feat: API key management endpoints and dashboard panel

Self-serve keys: GET/POST /api/keys, DELETE /api/keys/:id (admins may
revoke any key), GET /api/admin/keys for a platform-wide view. Tokens are
qrk_-prefixed, stored only as a SHA-256 hash, and returned exactly once at
creation. Read-scope keys are rejected with 403 on non-GET requests; key
creation and revocation are recorded in the audit log.

Dashboard gets a self-serve key panel with copy-once token display, scope
badges, and last-used timestamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 01:39:50 -05:00
parent a1cb6ec5f2
commit 8f2bfd23d2
2 changed files with 142 additions and 0 deletions
+66
View File
@@ -1681,6 +1681,72 @@ def import_links():
return jsonify({'created': created, 'errors': errors})
# ─────────────────────────────────────────────
# API keys — self-serve programmatic access
# Tokens look like qrk_… and are shown exactly once at creation.
# Only a SHA-256 hash is stored.
# ─────────────────────────────────────────────
@app.route('/api/keys', methods=['GET'])
@login_required
def list_api_keys():
with get_db() as conn:
rows = conn.execute(
'SELECT id, name, prefix, scope, created_at, last_used_at, revoked '
'FROM api_keys WHERE user_id=? ORDER BY created_at DESC',
(session.get('user_id'),)
).fetchall()
return jsonify({'keys': [dict(r) for r in rows]})
@app.route('/api/keys', methods=['POST'])
@login_required
def create_api_key():
data = request.get_json(silent=True) or {}
name = (data.get('name') or '').strip()[:64] or 'API key'
scope = (data.get('scope') or 'read').lower()
if scope not in ('read', 'write'):
return jsonify({'error': "scope must be 'read' or 'write'"}), 400
token = 'qrk_' + secrets.token_urlsafe(32)
key_hash = hashlib.sha256(token.encode()).hexdigest()
prefix = token[:12]
with get_db() as conn:
cur = conn.execute(
'INSERT INTO api_keys (user_id, name, key_hash, prefix, scope, created_at) VALUES (?,?,?,?,?,?)',
(session.get('user_id'), name, key_hash, prefix, scope, now_iso())
)
key_id = cur.lastrowid
log_audit(conn, 'api_key.create', target=prefix, details=f'name={name} scope={scope}')
return jsonify({
'id': key_id, 'name': name, 'prefix': prefix, 'scope': scope,
'token': token, # shown once — not retrievable later
}), 201
@app.route('/api/keys/<int:key_id>', methods=['DELETE'])
@login_required
def revoke_api_key(key_id):
with get_db() as conn:
key = conn.execute('SELECT * FROM api_keys WHERE id=?', (key_id,)).fetchone()
if not key or (key['user_id'] != session.get('user_id') and not session.get('is_admin')):
return jsonify({'error': 'Not found'}), 404
conn.execute('UPDATE api_keys SET revoked=1 WHERE id=?', (key_id,))
log_audit(conn, 'api_key.revoke', target=key['prefix'])
return jsonify({'success': True})
@app.route('/api/admin/keys', methods=['GET'])
@admin_required
def admin_list_api_keys():
with get_db() as conn:
rows = conn.execute(
'SELECT k.id, k.name, k.prefix, k.scope, k.created_at, k.last_used_at, k.revoked, '
'u.username FROM api_keys k JOIN users u ON k.user_id=u.id '
'ORDER BY k.created_at DESC'
).fetchall()
return jsonify({'keys': [dict(r) for r in rows]})
# ─────────────────────────────────────────────
# Admin — User Management
# ─────────────────────────────────────────────