From a880c41d89f84d0e2673b371d59acfcfaf1b3990 Mon Sep 17 00:00:00 2001 From: Kelly Date: Thu, 11 Dec 2025 09:16:27 -0700 Subject: [PATCH] feat: Add password confirmation for worker scaling + RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/auth/verify-password endpoint for re-authentication - Add PasswordConfirmModal component for sensitive actions - Worker scaling (+/-) now requires password confirmation - Add RBAC (ServiceAccount, Role, RoleBinding) for scraper pod - Scraper pod can now read/scale worker deployment via k8s API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/auth.ts | 23 +++ .../src/components/PasswordConfirmModal.tsx | 138 ++++++++++++++++++ cannaiq/src/lib/api.ts | 7 + cannaiq/src/pages/TasksDashboard.tsx | 33 ++++- k8s/scraper-rbac.yaml | 36 +++++ k8s/scraper.yaml | 1 + 6 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 cannaiq/src/components/PasswordConfirmModal.tsx create mode 100644 k8s/scraper-rbac.yaml diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 176e0579..dda13b68 100755 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -47,4 +47,27 @@ router.post('/refresh', authMiddleware, async (req: AuthRequest, res) => { res.json({ token }); }); +// Verify password for sensitive actions (requires current user to be authenticated) +router.post('/verify-password', authMiddleware, async (req: AuthRequest, res) => { + try { + const { password } = req.body; + + if (!password) { + return res.status(400).json({ error: 'Password required' }); + } + + // Re-authenticate the current user with the provided password + const user = await authenticateUser(req.user!.email, password); + + if (!user) { + return res.status(401).json({ error: 'Invalid password', verified: false }); + } + + res.json({ verified: true }); + } catch (error) { + console.error('Password verification error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + export default router; diff --git a/cannaiq/src/components/PasswordConfirmModal.tsx b/cannaiq/src/components/PasswordConfirmModal.tsx new file mode 100644 index 00000000..425f4f8d --- /dev/null +++ b/cannaiq/src/components/PasswordConfirmModal.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect, useRef } from 'react'; +import { api } from '../lib/api'; +import { Shield, X, Loader2 } from 'lucide-react'; + +interface PasswordConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description: string; +} + +export function PasswordConfirmModal({ + isOpen, + onClose, + onConfirm, + title, + description, +}: PasswordConfirmModalProps) { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setPassword(''); + setError(''); + // Focus the input when modal opens + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isOpen]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) { + setError('Password is required'); + return; + } + + setLoading(true); + setError(''); + + try { + const result = await api.verifyPassword(password); + if (result.verified) { + onConfirm(); + onClose(); + } else { + setError('Invalid password'); + } + } catch (err: any) { + setError(err.message || 'Verification failed'); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ +
+

{title}

+
+ +
+ + {/* Body */} +
+
+

{description}

+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500" + placeholder="Password" + disabled={loading} + /> + {error && ( +

{error}

+ )} +
+
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index bacf9f56..43ab3fd7 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -84,6 +84,13 @@ class ApiClient { }); } + async verifyPassword(password: string) { + return this.request<{ verified: boolean; error?: string }>('/api/auth/verify-password', { + method: 'POST', + body: JSON.stringify({ password }), + }); + } + async getMe() { return this.request<{ user: any }>('/api/auth/me'); } diff --git a/cannaiq/src/pages/TasksDashboard.tsx b/cannaiq/src/pages/TasksDashboard.tsx index 670ae295..53d9acd4 100644 --- a/cannaiq/src/pages/TasksDashboard.tsx +++ b/cannaiq/src/pages/TasksDashboard.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { api } from '../lib/api'; import { Layout } from '../components/Layout'; +import { PasswordConfirmModal } from '../components/PasswordConfirmModal'; import { ListChecks, Clock, @@ -149,6 +150,10 @@ export default function TasksDashboard() { const [workerReady, setWorkerReady] = useState(0); const [scalingWorkers, setScalingWorkers] = useState(false); + // Password confirmation for scaling + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [pendingScaleDelta, setPendingScaleDelta] = useState(0); + // Filters const [roleFilter, setRoleFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); @@ -185,10 +190,19 @@ export default function TasksDashboard() { } }; - const scaleWorkers = async (delta: number) => { + // Request to scale workers - shows confirmation modal first + const requestScaleWorkers = (delta: number) => { const newReplicas = Math.max(0, Math.min(50, workerReplicas + delta)); if (newReplicas === workerReplicas) return; + setPendingScaleDelta(delta); + setShowConfirmModal(true); + }; + + // Actually scale workers after password confirmation + const executeScaleWorkers = async () => { + const newReplicas = Math.max(0, Math.min(50, workerReplicas + pendingScaleDelta)); + setScalingWorkers(true); try { const res = await api.scaleK8sWorkers(newReplicas); @@ -201,6 +215,7 @@ export default function TasksDashboard() { setError(err.message || 'Failed to scale workers'); } finally { setScalingWorkers(false); + setPendingScaleDelta(0); } }; @@ -275,7 +290,7 @@ export default function TasksDashboard() {
+ + {/* Password Confirmation Modal for Worker Scaling */} + { + setShowConfirmModal(false); + setPendingScaleDelta(0); + }} + onConfirm={executeScaleWorkers} + title="Confirm Worker Scaling" + description={`You are about to ${pendingScaleDelta > 0 ? 'add' : 'remove'} ${Math.abs(pendingScaleDelta)} workers (${workerReplicas} → ${Math.max(0, Math.min(50, workerReplicas + pendingScaleDelta))}). This action affects production infrastructure.`} + /> ); } diff --git a/k8s/scraper-rbac.yaml b/k8s/scraper-rbac.yaml new file mode 100644 index 00000000..8ff05880 --- /dev/null +++ b/k8s/scraper-rbac.yaml @@ -0,0 +1,36 @@ +# RBAC configuration for scraper pod to control worker scaling +# Allows the scraper to read and scale the scraper-worker deployment +apiVersion: v1 +kind: ServiceAccount +metadata: + name: scraper-sa + namespace: dispensary-scraper +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: worker-scaler + namespace: dispensary-scraper +rules: + # Allow reading deployment status + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list"] + # Allow scaling deployments (read/write the scale subresource) + - apiGroups: ["apps"] + resources: ["deployments/scale"] + verbs: ["get", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: scraper-worker-scaler + namespace: dispensary-scraper +subjects: + - kind: ServiceAccount + name: scraper-sa + namespace: dispensary-scraper +roleRef: + kind: Role + name: worker-scaler + apiGroup: rbac.authorization.k8s.io diff --git a/k8s/scraper.yaml b/k8s/scraper.yaml index a7359d65..3cad6e82 100644 --- a/k8s/scraper.yaml +++ b/k8s/scraper.yaml @@ -25,6 +25,7 @@ spec: labels: app: scraper spec: + serviceAccountName: scraper-sa imagePullSecrets: - name: regcred containers: