diff --git a/client/src/app/api/org/command-policies/[id]/route.ts b/client/src/app/api/org/command-policies/[id]/route.ts new file mode 100644 index 000000000..05305ae1d --- /dev/null +++ b/client/src/app/api/org/command-policies/[id]/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; +const TIMEOUT_MS = 20000; + +async function proxy(method: string, path: string, body?: unknown) { + if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(`${API_BASE_URL}${path}`, { + method, + headers: { ...authResult.headers, "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return NextResponse.json({ error: "Timeout" }, { status: 504 }); + return NextResponse.json({ error: "Backend request failed" }, { status: 500 }); + } finally { + clearTimeout(timer); + } +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const body = await req.json(); + return proxy("PUT", `/api/org/command-policies/${id}`, body); +} + +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return proxy("DELETE", `/api/org/command-policies/${id}`); +} diff --git a/client/src/app/api/org/command-policies/route.ts b/client/src/app/api/org/command-policies/route.ts new file mode 100644 index 000000000..babf5c783 --- /dev/null +++ b/client/src/app/api/org/command-policies/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; +const TIMEOUT_MS = 20000; + +async function proxyRequest(req: NextRequest, method: string, path: string, body?: unknown) { + if (!API_BASE_URL) { + return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + } + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(`${API_BASE_URL}${path}`, { + method, + headers: { ...authResult.headers, "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); + } catch (e) { + if (e instanceof Error && e.name === "AbortError") { + return NextResponse.json({ error: "Request timeout" }, { status: 504 }); + } + return NextResponse.json({ error: "Backend request failed" }, { status: 500 }); + } finally { + clearTimeout(timer); + } +} + +export async function GET() { + return proxyRequest(new NextRequest("http://localhost"), "GET", "/api/org/command-policies"); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + return proxyRequest(req, "POST", "/api/org/command-policies", body); +} diff --git a/client/src/app/api/org/command-policies/test/route.ts b/client/src/app/api/org/command-policies/test/route.ts new file mode 100644 index 000000000..520d6f911 --- /dev/null +++ b/client/src/app/api/org/command-policies/test/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + const body = await req.json(); + const res = await fetch(`${API_BASE_URL}/api/org/command-policies/test`, { + method: "POST", + headers: { ...authResult.headers, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/client/src/app/api/org/command-policy-templates/active/route.ts b/client/src/app/api/org/command-policy-templates/active/route.ts new file mode 100644 index 000000000..19dcdda27 --- /dev/null +++ b/client/src/app/api/org/command-policy-templates/active/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; + +export async function DELETE() { + if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + const res = await fetch(`${API_BASE_URL}/api/org/command-policy-templates/active`, { + method: "DELETE", + headers: authResult.headers, + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/client/src/app/api/org/command-policy-templates/apply/route.ts b/client/src/app/api/org/command-policy-templates/apply/route.ts new file mode 100644 index 000000000..027470d95 --- /dev/null +++ b/client/src/app/api/org/command-policy-templates/apply/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + const body = await req.json(); + const res = await fetch(`${API_BASE_URL}/api/org/command-policy-templates/apply`, { + method: "POST", + headers: { ...authResult.headers, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/client/src/app/api/org/command-policy-templates/route.ts b/client/src/app/api/org/command-policy-templates/route.ts new file mode 100644 index 000000000..50f5c86e3 --- /dev/null +++ b/client/src/app/api/org/command-policy-templates/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; + +export async function GET() { + if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + const res = await fetch(`${API_BASE_URL}/api/org/command-policy-templates`, { + method: "GET", + headers: { ...authResult.headers, "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/client/src/app/api/org/command-policy-toggle/route.ts b/client/src/app/api/org/command-policy-toggle/route.ts new file mode 100644 index 000000000..37fba9b3c --- /dev/null +++ b/client/src/app/api/org/command-policy-toggle/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthenticatedUser } from "@/lib/auth-helper"; + +const API_BASE_URL = process.env.BACKEND_URL; + +export async function PUT(req: NextRequest) { + if (!API_BASE_URL) return NextResponse.json({ error: "BACKEND_URL not configured" }, { status: 500 }); + const authResult = await getAuthenticatedUser(); + if (authResult instanceof NextResponse) return authResult; + const body = await req.json(); + const res = await fetch(`${API_BASE_URL}/api/org/command-policy-toggle`, { + method: "PUT", + headers: { ...authResult.headers, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/client/src/app/globals.css b/client/src/app/globals.css index 0383c4075..687ed5df6 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -140,6 +140,23 @@ -ms-overflow-style: none; } +.scrollbar-thin::-webkit-scrollbar { + height: 1px; +} +.scrollbar-thin::-webkit-scrollbar-track { + background: transparent; +} +.scrollbar-thin::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 2px; +} +.scrollbar-thin:hover::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.25); +} +.scrollbar-thin { + scrollbar-width: thin; +} + /* Code block styles - using Prism okaidia theme */ /* Only override font properties, preserve Prism.js colors */ pre[class*="language-"] { diff --git a/client/src/components/SecuritySettings.tsx b/client/src/components/SecuritySettings.tsx new file mode 100644 index 000000000..6fa605793 --- /dev/null +++ b/client/src/components/SecuritySettings.tsx @@ -0,0 +1,601 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/components/ui/use-toast"; +import { useUser } from "@/hooks/useAuthHooks"; +import { isAdmin } from "@/lib/roles"; +import { Trash2, Plus, Terminal, ChevronRight, ChevronDown, Loader2, Lock, CheckCircle2, XCircle, Shield, ShieldCheck, ShieldX, BookOpen } from "lucide-react"; +import { commandPolicyService, type CommandPolicyRule, type PolicyTemplate } from "@/lib/services/command-policies"; + +function RuleList({ + rules, + onToggle, + onDelete, +}: { + rules: CommandPolicyRule[]; + onToggle: (rule: CommandPolicyRule) => void; + onDelete: (id: number) => void; +}) { + if (rules.length === 0) { + return ( +

No rules yet

+ ); + } + return ( +
+ {rules.map((rule) => ( +
+ onToggle(rule)} + className="shrink-0 scale-90" + aria-label={`Toggle rule: ${rule.pattern}`} + /> + + {rule.pattern} + + + {rule.description} + + {rule.source === "template" && ( + tpl + )} + +
+ ))} +
+ ); +} + +function AddRuleForm({ + mode, + onAdd, + onCancel, +}: { + mode: "allow" | "deny"; + onAdd: (pattern: string, description: string) => void; + onCancel: () => void; +}) { + const [pattern, setPattern] = useState(""); + const [desc, setDesc] = useState(""); + + return ( +
+
+
+ + setPattern(e.target.value)} + placeholder={mode === "deny" ? "\\bsudo\\b" : "^ls\\b"} + className="font-mono text-xs bg-background h-7" + /> +
+
+ + setDesc(e.target.value)} + placeholder={mode === "deny" ? "Block sudo" : "Allow ls"} + className="bg-background h-7 text-xs" + /> +
+
+
+ + +
+
+ ); +} + +function TemplatePicker({ + templates, + applying, + activeId, + onApply, + onRemove, +}: { + templates: PolicyTemplate[]; + applying: string | null; + activeId: string | null; + onApply: (id: string) => void; + onRemove: () => void; +}) { + const [confirmId, setConfirmId] = useState(null); + const [expandedId, setExpandedId] = useState(null); + + if (templates.length === 0) return null; + + return ( +
+
+
+ +
+
+

Policy Templates

+

Pre-built security profiles for common use cases

+
+
+
+ {templates.map((tpl) => { + const expanded = expandedId === tpl.id; + const active = activeId === tpl.id; + return ( +
+
+
+

+ {tpl.name} +

+

+ {tpl.description} +

+
+
+ {active ? ( +
+ + Active + + +
+ ) : confirmId === tpl.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ + + + {tpl.allow_count} allow + + + + {tpl.deny_count} deny + +
+ {expanded && ( +
+ {tpl.allow.length > 0 && ( +
+

Allow

+ {tpl.allow.map((r, i) => ( +
+ + {r.pattern} + + {r.description} +
+ ))} +
+ )} + {tpl.deny.length > 0 && ( +
+

Deny

+ {tpl.deny.map((r, i) => ( +
+ + {r.pattern} + + {r.description} +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} + +export function SecuritySettings() { + const { user } = useUser(); + const { toast } = useToast(); + const admin = isAdmin(user?.role); + + const [allowRules, setAllowRules] = useState([]); + const [denyRules, setDenyRules] = useState([]); + const [allowlistEnabled, setAllowlistEnabled] = useState(false); + const [denylistEnabled, setDenylistEnabled] = useState(false); + const [loading, setLoading] = useState(true); + + const [showAddAllow, setShowAddAllow] = useState(false); + const [showAddDeny, setShowAddDeny] = useState(false); + + const [testCmd, setTestCmd] = useState(""); + const [testResult, setTestResult] = useState<{ allowed: boolean; rule_description: string | null } | null>(null); + const [testLoading, setTestLoading] = useState(false); + + const [templates, setTemplates] = useState([]); + const [applyingTemplate, setApplyingTemplate] = useState(null); + const [activeTemplateId, setActiveTemplateId] = useState(null); + + const fetchPolicies = useCallback(async () => { + try { + const data = await commandPolicyService.getPolicies(); + setAllowRules(data.allow_rules); + setDenyRules(data.deny_rules); + setAllowlistEnabled(data.allowlist_enabled); + setDenylistEnabled(data.denylist_enabled); + setActiveTemplateId(data.active_template_id ?? null); + } catch { + toast({ title: "Failed to load policies", variant: "destructive" }); + } finally { + setLoading(false); + } + }, [toast]); + + const fetchTemplates = useCallback(async () => { + try { + const data = await commandPolicyService.getTemplates(); + setTemplates(data); + } catch { + // Templates are optional; don't block the UI + } + }, []); + + useEffect(() => { fetchPolicies(); fetchTemplates(); }, [fetchPolicies, fetchTemplates]); + + const handleToggleList = async (list: "allowlist" | "denylist", enabled: boolean) => { + try { + const res = await commandPolicyService.toggleList(list, enabled); + setAllowlistEnabled(res.allowlist_enabled); + setDenylistEnabled(res.denylist_enabled); + await fetchPolicies(); + toast({ title: `${list === "allowlist" ? "Allowlist" : "Denylist"} ${enabled ? "enabled" : "disabled"}` }); + } catch { + toast({ title: "Failed to toggle list", variant: "destructive" }); + } + }; + + const handleAddRule = async (mode: "allow" | "deny", pattern: string, description: string) => { + try { + await commandPolicyService.createPolicy({ mode, pattern, description, priority: 50 }); + mode === "allow" ? setShowAddAllow(false) : setShowAddDeny(false); + await fetchPolicies(); + toast({ title: "Rule added" }); + } catch (e) { + toast({ title: e instanceof Error ? e.message : "Failed to add rule", variant: "destructive" }); + } + }; + + const handleToggleRule = async (rule: CommandPolicyRule) => { + try { + await commandPolicyService.updatePolicy(rule.id, { enabled: !rule.enabled }); + await fetchPolicies(); + } catch { + toast({ title: "Failed to update rule", variant: "destructive" }); + } + }; + + const handleDeleteRule = async (id: number) => { + try { + await commandPolicyService.deletePolicy(id); + await fetchPolicies(); + } catch { + toast({ title: "Failed to delete rule", variant: "destructive" }); + } + }; + + const handleTest = async () => { + if (!testCmd.trim()) return; + setTestLoading(true); + try { + const result = await commandPolicyService.testCommand(testCmd.trim()); + setTestResult(result); + } catch { + toast({ title: "Test failed", variant: "destructive" }); + } finally { + setTestLoading(false); + } + }; + + const handleApplyTemplate = async (templateId: string) => { + setApplyingTemplate(templateId); + try { + const res = await commandPolicyService.applyTemplate(templateId); + setAllowlistEnabled(res.allowlist_enabled); + setDenylistEnabled(res.denylist_enabled); + await fetchPolicies(); + const tpl = templates.find(t => t.id === templateId); + toast({ title: `Applied "${tpl?.name ?? templateId}" template` }); + } catch { + toast({ title: "Failed to apply template", variant: "destructive" }); + } finally { + setApplyingTemplate(null); + } + }; + + const handleRemoveTemplate = async () => { + try { + await commandPolicyService.clearActiveTemplate(); + await fetchPolicies(); + toast({ title: "Template removed" }); + } catch { + toast({ title: "Failed to remove template", variant: "destructive" }); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!admin) { + return ( +
+
+
+ +
+
+

Command Policies

+

Organization-level security rules

+
+
+ + +
+ +

Requires Admin role to manage policies.

+
+ {denylistEnabled && Denylist on} + {allowlistEnabled && Allowlist on} + {!denylistEnabled && !allowlistEnabled && ( + No active lists + )} +
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

Command Policies

+

Control what commands the Aurora agent can execute

+
+
+ +

+ Command policies audit what the agent can execute — they do not change your connector + permissions or read-only mode settings. A command allowed here can still fail if the + underlying credentials lack access. If the denylist is on, matching commands are blocked. + If the allowlist is on, non-matching commands are blocked. Enable both for maximum control. +

+ + {/* Policy Templates */} + {templates.length > 0 && ( + + )} + + {/* Denylist */} +
+
+
+
+ +
+
+

Denylist

+

+ {denylistEnabled ? "Commands matching these patterns are blocked" : "Disabled -- no commands are blocked by this list"} +

+
+
+
+ {denylistEnabled && ( + + )} + handleToggleList("denylist", v)} + className="scale-90" + aria-label="Toggle denylist" + /> +
+
+ {denylistEnabled && ( + <> + + {showAddDeny && ( + handleAddRule("deny", p, d)} + onCancel={() => setShowAddDeny(false)} + /> + )} + + )} +
+ + {/* Allowlist */} +
+
+
+
+ +
+
+

Allowlist

+

+ {allowlistEnabled ? "Only commands matching these patterns are allowed" : "Disabled -- commands are not filtered by this list"} +

+
+
+
+ {allowlistEnabled && ( + + )} + handleToggleList("allowlist", v)} + className="scale-90" + aria-label="Toggle allowlist" + /> +
+
+ {allowlistEnabled && ( + <> + + {showAddAllow && ( + handleAddRule("allow", p, d)} + onCancel={() => setShowAddAllow(false)} + /> + )} + + )} +
+ + {/* Test Command */} +
+
+
+ +
+
+

Test Command

+

Check how a command would be evaluated

+
+
+
+
+ { setTestCmd(e.target.value); setTestResult(null); }} + placeholder="Enter a command to test..." + className="font-mono text-xs bg-background h-8" + onKeyDown={(e) => e.key === "Enter" && handleTest()} + /> + +
+ {testResult && ( +
+ {testResult.allowed ? ( + + ) : ( + + )} +
+

+ {testResult.allowed ? "Allowed" : "Denied"} +

+

+ {testResult.rule_description || "No matching rule"} +

+
+
+ )} +
+
+
+ ); +} diff --git a/client/src/components/SettingsModal.tsx b/client/src/components/SettingsModal.tsx index 6616ea275..8e4bf1d8c 100644 --- a/client/src/components/SettingsModal.tsx +++ b/client/src/components/SettingsModal.tsx @@ -3,13 +3,14 @@ import React, { useState } from 'react'; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; -import { Settings, User, BookOpen, FileText, Building2 } from "lucide-react"; +import { Settings, User, BookOpen, FileText, Building2, Shield } from "lucide-react"; import { cn } from "@/lib/utils"; import { GeneralSettings } from "@/components/GeneralSettings"; import { ProfileSettings } from "@/components/ProfileSettings"; import { KnowledgeBaseSettings } from "@/components/KnowledgeBaseSettings"; import { PostmortemsSettings } from "@/components/PostmortemsSettings"; import { OrgSettings } from "@/components/OrgSettings"; +import { SecuritySettings } from "@/components/SecuritySettings"; import { useUser } from "@/hooks/useAuthHooks"; @@ -18,7 +19,7 @@ interface SettingsModalProps { onClose: () => void; } -type SettingsTab = 'organization' | 'general' | 'profile' | 'knowledge-base' | 'postmortems'; +type SettingsTab = 'organization' | 'general' | 'profile' | 'knowledge-base' | 'postmortems' | 'security'; export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const [activeTab, setActiveTab] = useState('organization'); @@ -55,6 +56,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { icon: FileText, description: 'View generated postmortems' }, + { + id: 'security' as SettingsTab, + label: 'Security', + icon: Shield, + description: 'Agent command policies' + }, ]; const renderContent = () => { @@ -105,6 +112,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { ); + case 'security': + return ( +
+ +
+ ); + default: return null; } @@ -151,7 +165,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { {/* Right Content Area */} -
+
{renderContent()}
diff --git a/client/src/lib/services/command-policies.ts b/client/src/lib/services/command-policies.ts new file mode 100644 index 000000000..b917b7b7b --- /dev/null +++ b/client/src/lib/services/command-policies.ts @@ -0,0 +1,75 @@ +import { apiGet, apiPost, apiPut, apiDelete } from "./api-client"; + +export interface CommandPolicyRule { + id: number; + mode: "allow" | "deny"; + pattern: string; + description: string; + priority: number; + enabled: boolean; + created_at?: string; + updated_at?: string; + updated_by?: string; + source?: "template" | "custom"; +} + +export interface PoliciesResponse { + allow_rules: CommandPolicyRule[]; + deny_rules: CommandPolicyRule[]; + allowlist_enabled: boolean; + denylist_enabled: boolean; + active_template_id: string | null; +} + +export interface TestResult { + allowed: boolean; + rule_description: string | null; + command: string; +} + +export interface PolicyTemplateRule { + pattern: string; + description: string; + priority: number; +} + +export interface PolicyTemplate { + id: string; + name: string; + description: string; + allow_count: number; + deny_count: number; + allow: PolicyTemplateRule[]; + deny: PolicyTemplateRule[]; +} + +export const commandPolicyService = { + getPolicies: () => apiGet("/api/org/command-policies"), + + createPolicy: (rule: Pick) => + apiPost<{ id: number }>("/api/org/command-policies", rule), + + updatePolicy: (id: number, fields: Partial) => + apiPut<{ status: string }>(`/api/org/command-policies/${id}`, fields), + + deletePolicy: (id: number) => + apiDelete<{ status: string }>(`/api/org/command-policies/${id}`), + + testCommand: (command: string) => + apiPost("/api/org/command-policies/test", { command }), + + toggleList: (list: "allowlist" | "denylist", enabled: boolean) => + apiPut<{ status: string; allowlist_enabled: boolean; denylist_enabled: boolean }>( + "/api/org/command-policy-toggle", { list, enabled } + ), + + getTemplates: () => apiGet("/api/org/command-policy-templates"), + + applyTemplate: (templateId: string) => + apiPost<{ status: string; template_id: string; allowlist_enabled: boolean; denylist_enabled: boolean; active_template_id: string | null }>( + "/api/org/command-policy-templates/apply", { template_id: templateId } + ), + + clearActiveTemplate: () => + apiDelete<{ status: string; allowlist_enabled: boolean; denylist_enabled: boolean; active_template_id: null }>("/api/org/command-policy-templates/active"), +}; diff --git a/server/chat/backend/agent/prompt/composer.py b/server/chat/backend/agent/prompt/composer.py index 01f6651f9..dc07a40ab 100644 --- a/server/chat/backend/agent/prompt/composer.py +++ b/server/chat/backend/agent/prompt/composer.py @@ -112,6 +112,22 @@ def build_prompt_segments( if state and hasattr(state, 'user_id'): knowledge_base_memory = build_knowledge_base_memory_segment(state.user_id) + # Build org-level command policy segment + security_policy = "" + if state and hasattr(state, 'user_id'): + try: + from utils.auth.stateless_auth import get_org_id_for_user + from utils.auth.command_policy import get_policy_prompt_text + org_id = get_org_id_for_user(state.user_id) + if org_id: + security_policy = get_policy_prompt_text(org_id) + except Exception as e: + logging.error("Failed to build security policy segment: %s", e) + security_policy = ( + "IMPORTANT: This organization has command policies but they could not be loaded. " + "Warn the user before running commands, as they may be denied by policy enforcement." + ) + return PromptSegments( system_invariant=system_invariant, provider_constraints=provider_constraints, @@ -127,11 +143,15 @@ def build_prompt_segments( manual_vm_access=manual_vm_access, knowledge_base_memory=knowledge_base_memory, integration_index=integration_index, + security_policy=security_policy, ) def assemble_system_prompt(segments: PromptSegments) -> str: # main prompt builder parts: List[str] = [] + # Security policy included early for visibility + if segments.security_policy: + parts.append(segments.security_policy) # Background mode comes first if present (important RCA context) if segments.background_mode: parts.append(segments.background_mode) @@ -160,4 +180,6 @@ def assemble_system_prompt(segments: PromptSegments) -> str: # main prompt buil parts.append(segments.terraform_validation) if segments.failure_recovery and not segments.background_mode: parts.append(segments.failure_recovery) + if segments.security_policy: + parts.append("REMINDER: Commands that violate the organization policy will be rejected. Do not attempt workarounds.") return "\n".join(parts) diff --git a/server/chat/backend/agent/prompt/provider_rules.py b/server/chat/backend/agent/prompt/provider_rules.py index 2fc46152c..b2e72efce 100644 --- a/server/chat/backend/agent/prompt/provider_rules.py +++ b/server/chat/backend/agent/prompt/provider_rules.py @@ -249,9 +249,7 @@ def build_regional_rules() -> str: def build_ephemeral_rules(mode: Optional[str]) -> str: - normalized_mode = (mode or "agent").strip().lower() - - if normalized_mode == "ask": + if (mode or "agent").strip().lower() == "ask": return ( "━━━ CRITICAL: CURRENT MODE ━━━\n" "MODE: ASK (READ-ONLY)\n\n" diff --git a/server/chat/backend/agent/prompt/schema.py b/server/chat/backend/agent/prompt/schema.py index d57010b76..f978ebec1 100644 --- a/server/chat/backend/agent/prompt/schema.py +++ b/server/chat/backend/agent/prompt/schema.py @@ -17,3 +17,4 @@ class PromptSegments: background_mode: str = "" # Background chat autonomous operation instructions knowledge_base_memory: str = "" # User's knowledge base memory context integration_index: str = "" # Skills-based: compact index of connected integrations + security_policy: str = "" # Org-level command allow/deny policy diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index e35e496b7..9c9703f60 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -1607,6 +1607,30 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi normalized_provider = _normalize_cloud_exec_provider(provider) provider = normalized_provider + + # Org command policy check -- must run before any execution path branches + # Prepend CLI prefix so patterns like ^aws\s+ match (cloud_exec receives + # the subcommand without the provider prefix, e.g. "ecs list-clusters"). + _CLI_PREFIX = {"aws": "aws", "gcp": "gcloud", "azure": "az", + "scaleway": "scw", "ovh": "ovhcloud"} + from utils.auth.command_policy import evaluate_compound_command + from utils.auth.stateless_auth import get_org_id_for_user + org_id = get_org_id_for_user(user_id) if user_id else None + prefix = _CLI_PREFIX.get(provider.lower(), "") + policy_cmd = f"{prefix} {command}" if prefix and not command.strip().startswith(prefix) else command + verdict = evaluate_compound_command(org_id, policy_cmd) + if not verdict.allowed: + reason = (verdict.rule_description or "Matched organization policy")[:200] + logger.warning("Policy denied cloud command for user %s (%s)", + user_id, reason) + return json.dumps({ + "success": False, + "error": f"Command blocked by organization policy: {reason}", + "code": "POLICY_DENIED", + "final_command": command, + "provider": provider.lower(), + }) + # Set up ISOLATED environment based on provider - NO GLOBAL STATE! isolated_env = None auth_command = None @@ -2008,6 +2032,7 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi "provider": provider.lower(), }) + # If command is potentially action, ask for confirmation first (after command processing) if not is_read_only_command(command): summary_msg = summarize_cloud_command(command) diff --git a/server/chat/backend/agent/tools/kubectl_onprem_tool.py b/server/chat/backend/agent/tools/kubectl_onprem_tool.py index 3c43988a9..bc542e3ab 100644 --- a/server/chat/backend/agent/tools/kubectl_onprem_tool.py +++ b/server/chat/backend/agent/tools/kubectl_onprem_tool.py @@ -41,7 +41,27 @@ def on_prem_kubectl( command = command.strip() if command.lower().startswith('kubectl '): command = command[8:].strip() - + + # Org command policy check against full kubectl command + from utils.auth.command_policy import evaluate_compound_command + from utils.auth.stateless_auth import get_org_id_for_user + full_command = f"kubectl {command}" + org_id = get_org_id_for_user(user_id) if user_id else None + verdict = evaluate_compound_command(org_id, full_command) + if not verdict.allowed: + reason = (verdict.rule_description or "Matched organization policy")[:200] + logger.warning("Policy denied kubectl_onprem command for user %s (%s)", + user_id, reason) + return json.dumps({ + 'success': False, + 'error': f"Command blocked by organization policy: {reason}", + 'code': 'POLICY_DENIED', + 'chat_output': f"$ {full_command}\nBlocked by organization policy: {reason}", + 'command': full_command, + 'return_code': 1, + 'provider': 'onprem_kubectl', + }) + # Call internal API on chatbot service try: chatbot_url = os.getenv('CHATBOT_INTERNAL_URL') diff --git a/server/chat/backend/agent/tools/tailscale_ssh_tool.py b/server/chat/backend/agent/tools/tailscale_ssh_tool.py index 8fe869dbd..261aaf213 100644 --- a/server/chat/backend/agent/tools/tailscale_ssh_tool.py +++ b/server/chat/backend/agent/tools/tailscale_ssh_tool.py @@ -15,6 +15,7 @@ import json import logging import os +import re from typing import Optional logger = logging.getLogger(__name__) @@ -210,12 +211,34 @@ def tailscale_ssh( "error": "Device hostname is required" }) + if not re.match(r'^[A-Za-z0-9._:\-]+$', device_hostname): + return json.dumps({ + "success": False, + "error": "Invalid device hostname" + }) + if not command or not command.strip(): return json.dumps({ "success": False, "error": "Command cannot be empty" }) + # Org command policy check (shared allow/deny firewall across all tools) + from utils.auth.command_policy import evaluate_compound_command + from utils.auth.stateless_auth import get_org_id_for_user + org_id = get_org_id_for_user(user_id) if user_id else None + verdict = evaluate_compound_command(org_id, command) + if not verdict.allowed: + reason = (verdict.rule_description or "Matched organization policy")[:200] + logger.warning("Policy denied tailscale_ssh command for user %s (%s)", + user_id, reason) + return json.dumps({ + "success": False, + "error": f"Command blocked by organization policy: {reason}", + "code": "POLICY_DENIED", + "provider": "tailscale_ssh", + }) + # Validate SSH user (basic sanitization) if not ssh_user or (not ssh_user.isalnum() and ssh_user not in ["root"]): ssh_user = "root" diff --git a/server/chat/backend/agent/tools/terminal_exec_tool.py b/server/chat/backend/agent/tools/terminal_exec_tool.py index bd3c1dbde..8ac47c310 100644 --- a/server/chat/backend/agent/tools/terminal_exec_tool.py +++ b/server/chat/backend/agent/tools/terminal_exec_tool.py @@ -262,7 +262,22 @@ def terminal_exec( cmd_lower = command.lower().strip() has_shell_syntax = _has_shell_metacharacters(command) allow_routing = not force_shell and not has_shell_syntax - + + # Org command policy check (shared allow/deny firewall across all tools) + from utils.auth.command_policy import evaluate_compound_command + from utils.auth.stateless_auth import get_org_id_for_user + org_id = get_org_id_for_user(user_id) if user_id else None + verdict = evaluate_compound_command(org_id, command) + if not verdict.allowed: + reason = (verdict.rule_description or "Matched organization policy")[:200] + logger.warning("Policy denied terminal command for user %s (%s)", + user_id, reason) + return json.dumps({ + "success": False, + "error": f"Command blocked by organization policy: {reason}", + "code": "POLICY_DENIED", + }) + # Define routing table for cloud commands # Provider=None means "use user's provider preference" (for kubectl which works with any cloud) CLOUD_ROUTES = [ diff --git a/server/main_compute.py b/server/main_compute.py index eeed854fa..65aa91411 100644 --- a/server/main_compute.py +++ b/server/main_compute.py @@ -265,6 +265,10 @@ def enforce_user_org_binding(): from routes.org_routes import org_bp app.register_blueprint(org_bp) +# --- Command Policy Routes --- +from routes.command_policies import command_policies_bp +app.register_blueprint(command_policies_bp) + # --- GitHub Integration Routes --- from routes.github.github import github_bp from routes.github.github_user_repos import github_user_repos_bp diff --git a/server/routes/command_policies.py b/server/routes/command_policies.py new file mode 100644 index 000000000..be1cc7b70 --- /dev/null +++ b/server/routes/command_policies.py @@ -0,0 +1,387 @@ +"""API routes for command policy management (allow/deny firewall rules). + +Blueprint: command_policies_bp +Prefix: /api/org +""" + +import json +import logging +from datetime import datetime + +from flask import Blueprint, jsonify, request + +from utils.auth.rbac_decorators import require_permission, require_auth_only +from utils.auth.stateless_auth import ( + get_org_id_from_request, + get_user_preference, + store_user_preference, +) +from utils.auth.command_policy import ( + evaluate_compound_command, + get_policy_templates, + get_seed_rules, + invalidate_cache, + validate_pattern, +) + +logger = logging.getLogger(__name__) + +command_policies_bp = Blueprint("command_policies", __name__, url_prefix="/api/org") + + +def _list_states(org_id: str) -> dict: + """Read allowlist/denylist toggle states from user_preferences.""" + org_key = f"__org__{org_id}" + al = get_user_preference(org_key, "command_policy_allowlist") or "off" + dl = get_user_preference(org_key, "command_policy_denylist") or "off" + at = get_user_preference(org_key, "command_policy_active_template") + return { + "allowlist_enabled": str(al).lower() == "on", + "denylist_enabled": str(dl).lower() == "on", + "active_template_id": at or None, + } + + +@command_policies_bp.route("/command-policies", methods=["GET", "OPTIONS"]) +@require_auth_only +def list_policies(user_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + from utils.db.connection_pool import db_pool + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, mode, pattern, description, priority, enabled, " + "created_at, updated_at, updated_by, source " + "FROM org_command_policies WHERE org_id = %s ORDER BY priority DESC", + (org_id,), + ) + rows = cur.fetchall() + + allow_rules = [] + deny_rules = [] + for r in rows: + rule = { + "id": r[0], "mode": r[1], "pattern": r[2], + "description": r[3] or "", "priority": r[4], + "enabled": r[5], + "created_at": r[6].isoformat() if r[6] else None, + "updated_at": r[7].isoformat() if r[7] else None, + "updated_by": r[8], + "source": r[9] or "custom", + } + (allow_rules if r[1] == "allow" else deny_rules).append(rule) + + states = _list_states(org_id) + return jsonify({ + "allow_rules": allow_rules, + "deny_rules": deny_rules, + **states, + }) + + +@command_policies_bp.route("/command-policies", methods=["POST"]) +@require_permission("admin", "access") +def create_policy(user_id): + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + data = request.get_json() or {} + mode = data.get("mode") + pattern = data.get("pattern", "").strip() + description = data.get("description", "").strip() + try: + priority = int(data.get("priority", 0)) + except (TypeError, ValueError): + return jsonify({"error": "priority must be an integer"}), 400 + + if mode not in ("allow", "deny"): + return jsonify({"error": "mode must be 'allow' or 'deny'"}), 400 + if not pattern: + return jsonify({"error": "pattern is required"}), 400 + + err = validate_pattern(pattern) + if err: + return jsonify({"error": "Invalid regex pattern"}), 400 + + from utils.db.connection_pool import db_pool + try: + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO org_command_policies " + "(org_id, mode, pattern, description, priority, updated_by) " + "VALUES (%s, %s, %s, %s, %s, %s) RETURNING id", + (org_id, mode, pattern, description, priority, user_id), + ) + new_id = cur.fetchone()[0] + conn.commit() + except Exception as e: + if "unique" in str(e).lower() or "duplicate" in str(e).lower(): + return jsonify({"error": "A rule with this mode and pattern already exists"}), 409 + raise + + invalidate_cache(org_id) + return jsonify({"id": new_id, "status": "created"}), 201 + + +@command_policies_bp.route("/command-policies/", methods=["PUT", "OPTIONS"]) +@require_permission("admin", "access") +def update_policy(user_id, rule_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + data = request.get_json() or {} + updates = [] + params = [] + + for field, col in [("mode", "mode"), ("pattern", "pattern"), + ("description", "description"), ("priority", "priority"), + ("enabled", "enabled")]: + if field in data: + if field == "mode" and data[field] not in ("allow", "deny"): + return jsonify({"error": "mode must be 'allow' or 'deny'"}), 400 + if field == "pattern": + err = validate_pattern(data[field]) + if err: + return jsonify({"error": "Invalid regex pattern"}), 400 + updates.append(f"{col} = %s") + params.append(data[field]) + + if not updates: + return jsonify({"error": "No fields to update"}), 400 + + updates.append("updated_at = %s") + params.append(datetime.utcnow()) + updates.append("updated_by = %s") + params.append(user_id) + params.extend([rule_id, org_id]) + + from utils.db.connection_pool import db_pool + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE org_command_policies SET {', '.join(updates)} " + "WHERE id = %s AND org_id = %s", + params, + ) + if cur.rowcount == 0: + return jsonify({"error": "Rule not found"}), 404 + conn.commit() + + invalidate_cache(org_id) + store_user_preference(f"__org__{org_id}", "command_policy_active_template", None) + return jsonify({"status": "updated"}) + + +@command_policies_bp.route("/command-policies/", methods=["DELETE"]) +@require_permission("admin", "access") +def delete_policy(user_id, rule_id): + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + from utils.db.connection_pool import db_pool + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM org_command_policies WHERE id = %s AND org_id = %s", + (rule_id, org_id), + ) + if cur.rowcount == 0: + return jsonify({"error": "Rule not found"}), 404 + conn.commit() + + invalidate_cache(org_id) + store_user_preference(f"__org__{org_id}", "command_policy_active_template", None) + return jsonify({"status": "deleted"}) + + +@command_policies_bp.route("/command-policies/test", methods=["POST", "OPTIONS"]) +@require_auth_only +def test_command(user_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + data = request.get_json() or {} + command = data.get("command", "").strip() + if not command: + return jsonify({"error": "command is required"}), 400 + + verdict = evaluate_compound_command(org_id, command) + return jsonify({ + "allowed": verdict.allowed, + "rule_description": verdict.rule_description, + "command": command, + }) + + +@command_policies_bp.route("/command-policy-toggle", methods=["PUT", "OPTIONS"]) +@require_permission("admin", "access") +def toggle_list(user_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + data = request.get_json() or {} + list_name = data.get("list") + enabled = data.get("enabled") + + if list_name not in ("allowlist", "denylist"): + return jsonify({"error": "list must be 'allowlist' or 'denylist'"}), 400 + if not isinstance(enabled, bool): + return jsonify({"error": "enabled must be a boolean"}), 400 + + pref_key = f"command_policy_{list_name}" + org_key = f"__org__{org_id}" + store_user_preference(org_key, pref_key, "on" if enabled else "off") + + # Auto-seed rules on first enable if list is empty + if enabled: + mode = "allow" if list_name == "allowlist" else "deny" + from utils.db.connection_pool import db_pool + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT COUNT(*) FROM org_command_policies " + "WHERE org_id = %s AND mode = %s", + (org_id, mode), + ) + count = cur.fetchone()[0] + + if count == 0: + seeds = get_seed_rules().get(mode, []) + for seed in seeds: + cur.execute( + "INSERT INTO org_command_policies " + "(org_id, mode, pattern, description, priority, updated_by) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (org_id, mode, seed["pattern"], + seed["description"], seed["priority"], user_id), + ) + conn.commit() + + invalidate_cache(org_id) + return jsonify({"status": "updated", **_list_states(org_id)}) + + +# --------------------------------------------------------------------------- +# Template library endpoints +# --------------------------------------------------------------------------- + +@command_policies_bp.route("/command-policy-templates", methods=["GET", "OPTIONS"]) +@require_auth_only +def list_templates(user_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + templates = get_policy_templates() + result = [] + for tpl in templates: + result.append({ + "id": tpl["id"], + "name": tpl["name"], + "description": tpl["description"], + "allow_count": len(tpl["allow"]), + "deny_count": len(tpl["deny"]), + "allow": tpl["allow"], + "deny": tpl["deny"], + }) + return jsonify(result) + + +@command_policies_bp.route("/command-policy-templates/apply", methods=["POST", "OPTIONS"]) +@require_permission("admin", "access") +def apply_template(user_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + data = request.get_json() or {} + template_id = data.get("template_id") + if not template_id: + return jsonify({"error": "template_id is required"}), 400 + + templates = {t["id"]: t for t in get_policy_templates()} + tpl = templates.get(template_id) + if not tpl: + return jsonify({"error": f"Unknown template: {template_id}"}), 400 + + org_key = f"__org__{org_id}" + pref_upsert = ( + "INSERT INTO user_preferences (user_id, org_id, preference_key, preference_value) " + "VALUES (%s, %s, %s, %s) " + "ON CONFLICT (user_id, org_id, preference_key) WHERE org_id IS NOT NULL DO UPDATE " + "SET preference_value = EXCLUDED.preference_value, updated_at = CURRENT_TIMESTAMP" + ) + from utils.db.connection_pool import db_pool + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM org_command_policies WHERE org_id = %s AND source = 'template'", + (org_id,), + ) + for mode_key in ("allow", "deny"): + for rule in tpl[mode_key]: + cur.execute( + "INSERT INTO org_command_policies " + "(org_id, mode, pattern, description, priority, updated_by, source) " + "VALUES (%s, %s, %s, %s, %s, %s, 'template')", + (org_id, mode_key, rule["pattern"], + rule["description"], rule["priority"], user_id), + ) + cur.execute(pref_upsert, (org_key, org_id, "command_policy_allowlist", json.dumps("on"))) + cur.execute(pref_upsert, (org_key, org_id, "command_policy_denylist", json.dumps("on"))) + cur.execute(pref_upsert, (org_key, org_id, "command_policy_active_template", json.dumps(template_id))) + conn.commit() + + invalidate_cache(org_id) + return jsonify({"status": "applied", "template_id": template_id, **_list_states(org_id)}) + + +@command_policies_bp.route("/command-policy-templates/active", methods=["DELETE", "OPTIONS"]) +@require_permission("admin", "access") +def clear_active_template(user_id): + if request.method == "OPTIONS": + return jsonify({}), 200 + + org_id = get_org_id_from_request() + if not org_id: + return jsonify({"error": "No organization context"}), 403 + + org_key = f"__org__{org_id}" + pref_upsert = ( + "INSERT INTO user_preferences (user_id, org_id, preference_key, preference_value) " + "VALUES (%s, %s, %s, %s) " + "ON CONFLICT (user_id, org_id, preference_key) WHERE org_id IS NOT NULL DO UPDATE " + "SET preference_value = EXCLUDED.preference_value, updated_at = CURRENT_TIMESTAMP" + ) + from utils.db.connection_pool import db_pool + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM org_command_policies WHERE org_id = %s AND source = 'template'", (org_id,)) + cur.execute(pref_upsert, (org_key, org_id, "command_policy_active_template", json.dumps(None))) + conn.commit() + + invalidate_cache(org_id) + return jsonify({"status": "cleared", **_list_states(org_id)}) diff --git a/server/utils/auth/command_policy.py b/server/utils/auth/command_policy.py new file mode 100644 index 000000000..210a0c918 --- /dev/null +++ b/server/utils/auth/command_policy.py @@ -0,0 +1,590 @@ +"""Org-level command policy engine (allowlist / denylist). + +Evaluates every command before execution against org-configured regex rules. +Two independent lists, each independently togglable: + + 1. Denylist (if enabled) - checked first. Match -> DENIED. + 2. Allowlist (if enabled) - checked second. Match -> ALLOWED, no match -> DENIED. + 3. Both off -> ALLOWED. + +Compound shell expressions (;, &&, ||, |, subshells) are decomposed and each +atomic command is evaluated independently. One denied sub-command blocks the +entire expression. + +Default for new orgs: both lists OFF (no enforcement until configured). +Fail-open on DB error: if rules cannot be fetched, commands are allowed. +""" + +import logging +import re +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +_CACHE_TTL = 30 # seconds + +_CacheEntry = Tuple[ + List["PolicyRule"], # allow rules + List["PolicyRule"], # deny rules + "ListStates", + float, # monotonic timestamp +] +_cache: Dict[str, _CacheEntry] = {} + + +@dataclass(frozen=True) +class CommandVerdict: + allowed: bool + rule_description: Optional[str] = None + + +@dataclass(frozen=True) +class PolicyRule: + id: int + mode: str + pattern: str + description: str + priority: int + compiled: re.Pattern = field(repr=False) + + +@dataclass(frozen=True) +class ListStates: + allowlist_enabled: bool + denylist_enabled: bool + + +def _compile_safe(pattern: str) -> Optional[re.Pattern]: + try: + return re.compile(pattern) + except re.error: + logger.warning("Invalid regex in policy rule, skipping: %s", pattern) + return None + + +def _fetch(org_id: str) -> Tuple[List[PolicyRule], List[PolicyRule], ListStates]: + """Load policy rules and list states for *org_id*.""" + allow_rules: List[PolicyRule] = [] + deny_rules: List[PolicyRule] = [] + states = ListStates(allowlist_enabled=False, denylist_enabled=False) + + try: + from utils.db.connection_pool import db_pool + from utils.auth.stateless_auth import get_user_preference + + with db_pool.get_admin_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, mode, pattern, description, priority " + "FROM org_command_policies " + "WHERE org_id = %s AND enabled = true " + "ORDER BY priority DESC", + (org_id,), + ) + for row in cur.fetchall(): + compiled = _compile_safe(row[2]) + if compiled is None: + continue + rule = PolicyRule( + id=row[0], mode=row[1], pattern=row[2], + description=row[3] or "", priority=row[4], + compiled=compiled, + ) + if rule.mode == "allow": + allow_rules.append(rule) + else: + deny_rules.append(rule) + + org_pref_key = f"__org__{org_id}" + al_raw = get_user_preference(org_pref_key, "command_policy_allowlist") or "off" + dl_raw = get_user_preference(org_pref_key, "command_policy_denylist") or "off" + states = ListStates( + allowlist_enabled=(str(al_raw).lower() == "on"), + denylist_enabled=(str(dl_raw).lower() == "on"), + ) + except Exception: + logger.exception("Failed to fetch command policies for org %s, fail-open", org_id) + + return allow_rules, deny_rules, states + + +def _get_cached(org_id: str) -> Tuple[List[PolicyRule], List[PolicyRule], ListStates]: + entry = _cache.get(org_id) + if entry is not None: + allow, deny, states, ts = entry + if time.monotonic() - ts < _CACHE_TTL: + return allow, deny, states + + allow, deny, states = _fetch(org_id) + _cache[org_id] = (allow, deny, states, time.monotonic()) + return allow, deny, states + + +def evaluate_command(org_id: Optional[str], command: str) -> CommandVerdict: + """Core gate. Returns whether *command* is allowed for *org_id*.""" + if not org_id: + logger.info("policy_check_skipped reason=no_org_context func=evaluate_command") + return CommandVerdict(allowed=True) + + allow_rules, deny_rules, states = _get_cached(org_id) + + if not states.denylist_enabled and not states.allowlist_enabled: + return CommandVerdict(allowed=True, rule_description="Policy lists are disabled") + + if states.denylist_enabled: + for rule in deny_rules: + if rule.compiled.search(command): + return CommandVerdict(allowed=False, rule_description=rule.description) + + if states.allowlist_enabled: + for rule in allow_rules: + if rule.compiled.search(command): + return CommandVerdict(allowed=True, rule_description=rule.description) + return CommandVerdict(allowed=False, rule_description="No matching allow rule") + + return CommandVerdict(allowed=True) + + +_UNSPLITTABLE_SHELL_RE = re.compile(r"<<-?\s*\w+|<\(|>\(") + + +def _split_compound_command(compound: str) -> List[str]: + """Quote-aware split of a shell expression into atomic commands. + + Splits on ; && || | while respecting single/double quotes and backslash + escapes. Recursively extracts commands from $(...) and backtick subshells + so they are evaluated independently. + + Falls back to evaluating the full string when heredocs or process + substitution are detected, since these constructs hide arbitrary content + from a naive splitter. + """ + if _UNSPLITTABLE_SHELL_RE.search(compound): + return [compound] + + commands: List[str] = [] + buf: List[str] = [] + sq = dq = False + i, n = 0, len(compound) + + def _flush() -> None: + s = "".join(buf).strip() + if s: + commands.append(s) + buf.clear() + + while i < n: + c = compound[i] + + # Backslash escape (not inside single quotes) + if c == "\\" and not sq and i + 1 < n: + buf += [c, compound[i + 1]] + i += 2 + continue + + # Quote toggling + if c == "'" and not dq: + sq = not sq + buf.append(c) + i += 1 + continue + if c == '"' and not sq: + dq = not dq + buf.append(c) + i += 1 + continue + + # Everything inside quotes is literal + if sq or dq: + buf.append(c) + i += 1 + continue + + # -- Outside quotes: detect operators and subshells -- + two = compound[i : i + 2] + + if two in ("&&", "||"): + _flush() + i += 2 + continue + + if c in (";", "|"): + _flush() + i += 1 + continue + + # $(...) subshell - extract inner commands for separate evaluation + if c == "$" and i + 1 < n and compound[i + 1] == "(": + j, depth = i + 2, 1 + while j < n and depth: + if compound[j] == "(": + depth += 1 + elif compound[j] == ")": + depth -= 1 + j += 1 + commands.extend(_split_compound_command(compound[i + 2 : j - 1])) + buf.append(compound[i:j]) + i = j + continue + + # Backtick subshell + if c == "`": + j = compound.find("`", i + 1) + if j != -1: + commands.extend(_split_compound_command(compound[i + 1 : j])) + buf.append(compound[i : j + 1]) + i = j + 1 + continue + + buf.append(c) + i += 1 + + _flush() + return commands + + +def evaluate_compound_command( + org_id: Optional[str], command: str +) -> CommandVerdict: + """Evaluate a potentially compound shell command. + + Decomposes the expression into atomic commands and evaluates each one + independently. ALL sub-commands must pass policy; the first denial is + returned immediately. + """ + if not org_id: + logger.info("policy_check_skipped reason=no_org_context func=evaluate_compound_command") + return CommandVerdict(allowed=True) + + parts = _split_compound_command(command) + if not parts: + return evaluate_command(org_id, command) + + last_verdict = CommandVerdict(allowed=True) + for part in parts: + verdict = evaluate_command(org_id, part) + if not verdict.allowed: + return verdict + last_verdict = verdict + + return last_verdict + + +def validate_pattern(pattern: str) -> Optional[str]: + """Return an error string if *pattern* is not valid regex, else None.""" + try: + re.compile(pattern) + return None + except re.error as exc: + return str(exc) + + +def get_policy_prompt_text(org_id: str) -> str: + """Render active policy as system-prompt text for the LLM.""" + allow_rules, deny_rules, states = _get_cached(org_id) + + if not states.denylist_enabled and not states.allowlist_enabled: + return "" + + lines = [ + "## Organization Command Policy", + "The following command policy is enforced. Commands violating this policy " + + "will be rejected at execution time. Do not attempt blocked commands.", + "", + ] + + if states.denylist_enabled and deny_rules: + lines.append("DENIED commands (never run these):") + for r in deny_rules: + lines.append(f" - {r.description} (pattern: {r.pattern})") + lines.append("") + + if states.allowlist_enabled and allow_rules: + lines.append("ALLOWED commands (only these are permitted):") + for r in allow_rules: + lines.append(f" - {r.description} (pattern: {r.pattern})") + lines.append("") + + return "\n".join(lines) + + +def get_seed_rules() -> Dict[str, list]: + """Default seed templates, inserted on first enable of each list. + + Returns the 'observability_only' template for backward compatibility. + """ + tpl = get_policy_templates()[0] + return {"allow": tpl["allow"], "deny": tpl["deny"]} + + +# --------------------------------------------------------------------------- +# Deny rules shared across ALL templates (dangerous regardless of access level) +# --------------------------------------------------------------------------- +_UNIVERSAL_DENY_RULES: list = [ + {"priority": 100, "pattern": r"\brm\s+-rf\s+/", + "description": "Recursive root deletion"}, + {"priority": 95, "pattern": r"\b(gcc|g\+\+|cc|make|as|ld)\b", + "description": "Native code compilation"}, + {"priority": 92, "pattern": r"(? List[dict]: + """Return the library of pre-built policy templates. + + Each template is a dict with keys: id, name, description, allow, deny. + Templates are ordered from most restrictive to most permissive. + """ + return [ + # -- 1. Observability Only ---------------------------------------- + { + "id": "observability_only", + "name": "Observability Only", + "description": ( + "Read-only access aligned with cloud provider read-only " + "credentials (AWS ReadOnlyAccess session policy, GCP " + "roles/viewer, Azure Reader). Blocks all write, SSH, and " + "interactive operations." + ), + "allow": [ + # Filesystem inspection + {"priority": 200, "pattern": r"^(ls|cat|head|tail|wc|grep|find|stat|file|du|df|sort|uniq|awk|sed|tr|cut|tee|less|more|xargs|realpath|readlink|basename|dirname)\b", + "description": "Read-only filesystem inspection"}, + # Kubernetes read-only + {"priority": 190, "pattern": r"^(kubectl|oc)\s+(get|describe|logs|top|explain|api-resources|api-versions|cluster-info|config\s+(view|get-contexts|current-context|use-context))\b", + "description": "Read-only Kubernetes queries"}, + # AWS CLI -- matches all read-only verbs and subcommands that the session policy permits + {"priority": 180, "pattern": r"^aws\s+\S+\s+(ls|list|describe[-\w]*|get[-\w]*|show[-\w]*|head[-\w]*|filter[-\w]*|start-query|stop-query|test-metric-filter|update-kubeconfig|wait)\b", + "description": "AWS read-only operations (EC2, EKS, S3, RDS, Lambda, IAM, CloudWatch, Logs, ECS, CloudFormation)"}, + {"priority": 179, "pattern": r"^aws\s+s3\s+(ls|cp\s+s3://|presign|sync\s+s3://)", + "description": "AWS S3 read operations (ls, download, presign)"}, + {"priority": 178, "pattern": r"^aws\s+sts\s+get-caller-identity\b", + "description": "AWS STS identity check"}, + {"priority": 177, "pattern": r"^aws\s+logs\s+(describe-log-groups|describe-log-streams|get-log-events|get-query-results|filter-log-events|start-query|stop-query|tail)\b", + "description": "AWS CloudWatch Logs read operations"}, + # GCP / gcloud -- roles/viewer, logging.viewer, monitoring.viewer, container.viewer, storage.objectViewer + {"priority": 170, "pattern": r"^gcloud\s+.+\b(list|describe|get|show|read|get-credentials|get-server-config)\b", + "description": "GCP read-only operations (Compute, GKE, Cloud SQL, Cloud Run, IAM, DNS)"}, + {"priority": 169, "pattern": r"^gcloud\s+(logging|monitoring|asset|projects|organizations|config)\s", + "description": "GCP logging, monitoring, asset inventory, and config"}, + {"priority": 168, "pattern": r"^gsutil\s+(ls|cat|stat|du|cp\s+gs://|rsync\s+-n)\b", + "description": "GCP Storage read operations (ls, cat, stat, download)"}, + {"priority": 167, "pattern": r"^bq\s+(ls|show|head|query\s+--dry_run|mk\s+--dry_run)\b", + "description": "BigQuery read-only operations"}, + # Azure -- Reader role + Log Analytics Reader + Monitoring Reader + {"priority": 160, "pattern": r"^az\s+.+\b(list|show|get|describe|display|query|download)\b", + "description": "Azure read-only operations (VMs, AKS, Storage, SQL, Key Vault, NSGs)"}, + {"priority": 159, "pattern": r"^az\s+(monitor|advisor|security|consumption|costmanagement|account)\s", + "description": "Azure monitoring, cost, security, and account queries"}, + {"priority": 158, "pattern": r"^az\s+aks\s+get-credentials\b", + "description": "Azure AKS kubeconfig retrieval"}, + # OVH / Scaleway + {"priority": 150, "pattern": r"^ovhcloud\s+.+\b(list|show|get|describe)\b", + "description": "OVH read-only operations"}, + {"priority": 149, "pattern": r"^scw\s+.+\b(list|get|describe|inspect)\b", + "description": "Scaleway read-only operations"}, + # Tailscale + {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", + "description": "Tailscale read-only operations"}, + # Terraform / IaC read-only + {"priority": 130, "pattern": r"^(terraform|tofu)\s+(init|plan|validate|fmt|output|show|state\s+(list|show|pull)|version)\b", + "description": "Non-destructive Terraform operations"}, + {"priority": 129, "pattern": r"^(helm)\s+(list|get|show|status|history|search|version)\b", + "description": "Helm read-only operations"}, + # Network diagnostics + {"priority": 120, "pattern": r"^(ping|dig|nslookup|traceroute|tracepath|mtr|curl|wget|host|whois|nmap)\b", + "description": "Network diagnostics"}, + # Git read-only + {"priority": 110, "pattern": r"^git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|rev-parse|config\s+--get|ls-files|ls-remote|blame|shortlog)\b", + "description": "Read-only git operations"}, + # Docker/container inspection + {"priority": 100, "pattern": r"^(docker|podman)\s+(ps|images|inspect|logs|stats|top|port|diff|history|version|info|network\s+(ls|inspect)|volume\s+(ls|inspect))\b", + "description": "Container inspection (read-only)"}, + # System diagnostics + {"priority": 90, "pattern": r"^(uptime|whoami|hostname|uname|env|printenv|id|date|cal|free|vmstat|iostat|mpstat|sar|lsof|ss|netstat|ps|top|htop|lscpu|lsmem|lsblk|mount|dmesg|journalctl|systemctl\s+(status|is-active|is-enabled|list-units|list-timers))\b", + "description": "System diagnostics and status"}, + # Process / text utilities + {"priority": 80, "pattern": r"^(jq|yq|column|printf|echo|test|true|false|which|type|command|whereis|file|xxd|hexdump|sha256sum|md5sum|base64)\b", + "description": "Text processing and utility commands"}, + ], + "deny": [ + *_UNIVERSAL_DENY_RULES, + {"priority": 50, "pattern": r"\bkubectl\s+(exec|cp|run|attach|port-forward|apply|delete|create|edit|patch|replace|scale|rollout|drain|cordon|uncordon|taint)\b", + "description": "Mutating kubectl operations"}, + {"priority": 48, "pattern": r"^(ssh|scp|sftp)\s", + "description": "SSH access (use Standard Operations template to enable)"}, + ], + }, + + # -- 2. Standard Operations ---------------------------------------- + { + "id": "standard_ops", + "name": "Standard Operations", + "description": ( + "Allows SSH, kubectl exec, cloud CLI config commands, and " + "container inspection on top of full read-only access. " + "Suitable for incident response and debugging. Still blocks " + "infrastructure mutations and dangerous patterns. " + "Note: kubectl run is allowed for ad-hoc pods; image constraints " + "should be enforced by cluster admission controllers." + ), + "allow": [ + # Filesystem + {"priority": 200, "pattern": r"^(ls|cat|head|tail|wc|grep|find|stat|file|du|df|sort|uniq|awk|sed|tr|cut|tee|less|more|xargs|realpath|readlink|basename|dirname)\b", + "description": "Filesystem inspection"}, + # Kubernetes read + interactive + {"priority": 190, "pattern": r"^(kubectl|oc)\s+(get|describe|logs|top|explain|api-resources|api-versions|cluster-info|config\s+(view|get-contexts|current-context|use-context)|exec|cp|attach|port-forward|run\s+.*--rm\b.*--restart=Never)\b", + "description": "Kubernetes read and interactive debug operations"}, + # AWS full read + config + {"priority": 180, "pattern": r"^aws\s+\S+\s+(ls|list|describe[-\w]*|get[-\w]*|show[-\w]*|head[-\w]*|filter[-\w]*|start-query|stop-query|test-metric-filter|update-kubeconfig|configure|wait)\b", + "description": "AWS read and config operations"}, + {"priority": 179, "pattern": r"^aws\s+s3\s+(ls|cp\s+s3://|presign|sync\s+s3://)", + "description": "AWS S3 read operations"}, + {"priority": 178, "pattern": r"^aws\s+sts\s+(get-caller-identity|assume-role|get-session-token)\b", + "description": "AWS STS operations"}, + {"priority": 177, "pattern": r"^aws\s+logs\s+(describe-log-groups|describe-log-streams|get-log-events|get-query-results|filter-log-events|start-query|stop-query|tail)\b", + "description": "AWS CloudWatch Logs operations"}, + # GCP full read + credentials + {"priority": 170, "pattern": r"^gcloud\s+.+\b(list|describe|get|show|read|get-credentials|get-server-config)\b", + "description": "GCP read and credential operations"}, + {"priority": 169, "pattern": r"^gcloud\s+(logging|monitoring|asset|projects|organizations|config|auth)\s", + "description": "GCP logging, monitoring, asset inventory, auth, and config"}, + {"priority": 168, "pattern": r"^gsutil\s+(ls|cat|stat|du|cp\s+gs://|rsync\s+-n)\b", + "description": "GCP Storage read operations"}, + {"priority": 167, "pattern": r"^bq\s+(ls|show|head|query|mk\s+--dry_run)\b", + "description": "BigQuery operations"}, + # Azure full read + credentials + {"priority": 160, "pattern": r"^az\s+.+\b(list|show|get|describe|display|query|download|browse)\b", + "description": "Azure read operations"}, + {"priority": 159, "pattern": r"^az\s+(monitor|advisor|security|consumption|costmanagement|account|aks\s+get-credentials)\s", + "description": "Azure monitoring, cost, security, and AKS credentials"}, + # OVH / Scaleway + {"priority": 150, "pattern": r"^ovhcloud\s+.+\b(list|show|get|describe)\b", + "description": "OVH read-only operations"}, + {"priority": 149, "pattern": r"^scw\s+.+\b(list|get|describe|inspect)\b", + "description": "Scaleway read-only operations"}, + # Tailscale + {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", + "description": "Tailscale read-only operations"}, + # SSH access + {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s", + "description": "SSH, SCP, and SFTP access"}, + # Terraform / IaC read-only + {"priority": 130, "pattern": r"^(terraform|tofu)\s+(init|plan|validate|fmt|output|show|state\s+(list|show|pull)|version)\b", + "description": "Non-destructive Terraform operations"}, + {"priority": 129, "pattern": r"^(helm)\s+(list|get|show|status|history|search|version|template)\b", + "description": "Helm read-only operations"}, + # Network diagnostics + {"priority": 120, "pattern": r"^(ping|dig|nslookup|traceroute|tracepath|mtr|curl|wget|host|whois|nmap)\b", + "description": "Network diagnostics"}, + # Git read-only + {"priority": 110, "pattern": r"^git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|rev-parse|config\s+--get|ls-files|ls-remote|blame|shortlog)\b", + "description": "Read-only git operations"}, + # Docker/container inspection + exec + {"priority": 100, "pattern": r"^(docker|podman)\s+(ps|images|inspect|logs|stats|top|port|diff|history|version|info|exec|network\s+(ls|inspect)|volume\s+(ls|inspect))\b", + "description": "Container inspection and exec"}, + # System diagnostics + {"priority": 90, "pattern": r"^(uptime|whoami|hostname|uname|env|printenv|id|date|cal|free|vmstat|iostat|mpstat|sar|lsof|ss|netstat|ps|top|htop|lscpu|lsmem|lsblk|mount|dmesg|journalctl|systemctl\s+(status|is-active|is-enabled|list-units|list-timers))\b", + "description": "System diagnostics and status"}, + # Text utilities + {"priority": 80, "pattern": r"^(jq|yq|column|printf|echo|test|true|false|which|type|command|whereis|file|xxd|hexdump|sha256sum|md5sum|base64)\b", + "description": "Text processing and utility commands"}, + ], + "deny": [ + *_UNIVERSAL_DENY_RULES, + {"priority": 50, "pattern": r"\bkubectl\s+(apply|delete|create|edit|patch|replace|scale|rollout|drain|cordon|uncordon|taint)\b", + "description": "Mutating kubectl operations (use Full Cloud Access to enable)"}, + ], + }, + + # -- 3. Full Cloud Access ------------------------------------------ + { + "id": "full_cloud_access", + "name": "Full Cloud Access", + "description": ( + "Broad command access for orgs with admin-level cloud " + "credentials. Allows cloud write operations, Terraform " + "apply, kubectl mutations, SSH, and Docker management. " + "Only blocks universally dangerous patterns." + ), + "allow": [ + # Filesystem -- broad + {"priority": 200, "pattern": r"^(ls|cat|head|tail|wc|grep|find|stat|file|du|df|sort|uniq|awk|sed|tr|cut|tee|less|more|xargs|realpath|readlink|basename|dirname|mkdir|cp|mv|touch|ln|chmod|chown|rm)\b", + "description": "Filesystem operations"}, + # Kubernetes -- full + {"priority": 190, "pattern": r"^(kubectl|oc)\s+\w", + "description": "All kubectl/oc operations"}, + # AWS -- broad + {"priority": 180, "pattern": r"^aws\s+\w", + "description": "All AWS CLI operations"}, + # GCP -- broad + {"priority": 170, "pattern": r"^(gcloud|gsutil|bq)\s+\w", + "description": "All GCP CLI operations"}, + # Azure -- broad + {"priority": 160, "pattern": r"^az\s+\w", + "description": "All Azure CLI operations"}, + # OVH / Scaleway -- broad + {"priority": 150, "pattern": r"^(ovhcloud|scw)\s+\w", + "description": "All OVH and Scaleway CLI operations"}, + # Tailscale + {"priority": 140, "pattern": r"^tailscale\s+\w", + "description": "All Tailscale operations"}, + # SSH + {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s", + "description": "SSH, SCP, and SFTP access"}, + # Terraform -- full + {"priority": 130, "pattern": r"^(terraform|tofu|pulumi)\s+\w", + "description": "All Terraform/Tofu/Pulumi operations"}, + {"priority": 129, "pattern": r"^(helm|helmfile)\s+\w", + "description": "All Helm operations"}, + {"priority": 128, "pattern": r"^(ansible|ansible-playbook|ansible-galaxy)\s", + "description": "All Ansible operations"}, + # Network diagnostics + {"priority": 120, "pattern": r"^(ping|dig|nslookup|traceroute|tracepath|mtr|curl|wget|host|whois|nmap)\b", + "description": "Network diagnostics"}, + # Git -- full + {"priority": 110, "pattern": r"^git\s+\w", + "description": "All git operations"}, + # Docker/container -- full + {"priority": 100, "pattern": r"^(docker|podman|docker-compose|ctr|crictl)\s+\w", + "description": "All container operations"}, + # System diagnostics + management + {"priority": 90, "pattern": r"^(uptime|whoami|hostname|uname|env|printenv|id|date|cal|free|vmstat|iostat|mpstat|sar|lsof|ss|netstat|ps|top|htop|lscpu|lsmem|lsblk|mount|dmesg|journalctl|systemctl)\b", + "description": "System diagnostics and service management"}, + # Text/utility + {"priority": 80, "pattern": r"^(jq|yq|column|printf|echo|test|true|false|which|type|command|whereis|file|xxd|hexdump|sha256sum|md5sum|base64|tar|gzip|gunzip|zip|unzip|xz)\b", + "description": "Text processing and archive utilities"}, + # Package managers (read) + {"priority": 70, "pattern": r"^(pip|npm|yarn|go|cargo|apt|yum|dnf|brew)\s+(list|show|info|search|outdated|version|--version)\b", + "description": "Package manager queries"}, + ], + "deny": list(_UNIVERSAL_DENY_RULES), + }, + ] + + +def invalidate_cache(org_id: str) -> None: + _cache.pop(org_id, None) diff --git a/server/utils/auth/stateless_auth.py b/server/utils/auth/stateless_auth.py index 6d8711ff3..d4d58334e 100644 --- a/server/utils/auth/stateless_auth.py +++ b/server/utils/auth/stateless_auth.py @@ -360,7 +360,7 @@ def store_user_preference(user_id: str, key: str, value: Any): cursor.execute(""" INSERT INTO user_preferences (user_id, org_id, preference_key, preference_value) VALUES (%s, %s, %s, %s) - ON CONFLICT (user_id, org_id, preference_key) DO UPDATE SET + ON CONFLICT (user_id, org_id, preference_key) WHERE org_id IS NOT NULL DO UPDATE SET preference_value = EXCLUDED.preference_value, updated_at = CURRENT_TIMESTAMP """, (user_id, org_id, key, json.dumps(value))) diff --git a/server/utils/db/db_utils.py b/server/utils/db/db_utils.py index 1dc3cbc90..bf57f8618 100644 --- a/server/utils/db/db_utils.py +++ b/server/utils/db/db_utils.py @@ -378,6 +378,24 @@ def initialize_tables(): UNIQUE(user_id, org_id, preference_key) ); """, + "org_command_policies": """ + CREATE TABLE IF NOT EXISTS org_command_policies ( + id SERIAL PRIMARY KEY, + org_id VARCHAR(255) NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + mode VARCHAR(20) NOT NULL CHECK (mode IN ('allow', 'deny')), + pattern TEXT NOT NULL, + description TEXT, + priority INT DEFAULT 0, + enabled BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(255), + source VARCHAR(20) DEFAULT 'custom' NOT NULL, + UNIQUE(org_id, mode, pattern, source) + ); + CREATE INDEX IF NOT EXISTS idx_ocp_org + ON org_command_policies(org_id); + """, "workspaces": """ CREATE TABLE IF NOT EXISTS workspaces ( id VARCHAR(50) PRIMARY KEY, @@ -1173,6 +1191,7 @@ def initialize_tables(): rls_tables.append("incident_lifecycle_events") rls_tables.append("github_connected_repos") rls_tables.append("execution_steps") + rls_tables.append("org_command_policies") # Migration: Add rca_celery_task_id column to incidents table if it doesn't exist