fix(jira): expose non-admin jira-config, drop security-test label; catalog paging
GET /system/jira-config was admin-only, so non-admin users hit a 403
and the frontend silently fell back to a hardcoded jira.atlassian.com
base URL when building /browse/{key} links. Opened it to any
authenticated user (still no secrets returned).
Also drops the redundant 'security-test' Jira label, and adds a
page-size selector (10/25/50/100) and a 'no existing test yet' filter
to the Test Catalog.
This commit is contained in:
@@ -17,7 +17,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import SessionLocal, get_db
|
from app.database import SessionLocal, get_db
|
||||||
from app.dependencies.auth import require_role
|
from app.dependencies.auth import require_role, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.mitre_sync_service import sync_mitre
|
from app.services.mitre_sync_service import sync_mitre
|
||||||
from app.services.intel_service import scan_intel
|
from app.services.intel_service import scan_intel
|
||||||
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
|
|||||||
@router.get("/jira-config", response_model=JiraConfigOut)
|
@router.get("/jira-config", response_model=JiraConfigOut)
|
||||||
def get_jira_config(
|
def get_jira_config(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Return current Jira configuration (merged DB + env).
|
"""Return current Jira configuration (merged DB + env).
|
||||||
|
|
||||||
**Requires** the ``admin`` role. Credential values are never returned.
|
Open to any authenticated user — the frontend needs ``url`` to build
|
||||||
|
correct ``/browse/{key}`` links for non-admin users too. Credential
|
||||||
|
values (tokens, admin email) are never returned, only booleans.
|
||||||
"""
|
"""
|
||||||
from app.services.jira_service import (
|
from app.services.jira_service import (
|
||||||
get_jira_url, get_jira_project_key, is_jira_enabled,
|
get_jira_url, get_jira_project_key, is_jira_enabled,
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ def auto_create_test_issue(
|
|||||||
|
|
||||||
poc = test.procedure_text or "N/A"
|
poc = test.procedure_text or "N/A"
|
||||||
severity = _technique_severity(technique).capitalize()
|
severity = _technique_severity(technique).capitalize()
|
||||||
labels = ["aegis", "security-test", mitre_id.replace(".", "-")]
|
labels = ["aegis", mitre_id.replace(".", "-")]
|
||||||
if test.platform:
|
if test.platform:
|
||||||
# Jira labels can't contain whitespace — normalize to kebab-case.
|
# Jira labels can't contain whitespace — normalize to kebab-case.
|
||||||
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
|
labels.append(re.sub(r"[^a-z0-9_-]+", "-", test.platform.lower()).strip("-"))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""GET /system/jira-config must be readable by any authenticated user.
|
||||||
|
|
||||||
|
Regression: it used to be admin-only, so non-admin users (e.g. red_lead)
|
||||||
|
got a 403 and the frontend silently fell back to a hardcoded
|
||||||
|
"https://jira.atlassian.com" base URL when building ticket links.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_can_read_jira_config(client, red_lead_headers):
|
||||||
|
resp = client.get("/api/v1/system/jira-config", headers=red_lead_headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert "url" in resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_cannot_update_jira_config(client, red_lead_headers):
|
||||||
|
resp = client.patch(
|
||||||
|
"/api/v1/system/jira-config",
|
||||||
|
json={"url": "https://evil.example.com"},
|
||||||
|
headers=red_lead_headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -414,11 +414,11 @@ def test_auto_create_test_issue_adds_platform_label(
|
|||||||
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
jira_service.auto_create_test_issue(db, test, actor, technique=technique)
|
||||||
|
|
||||||
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
labels = mock_jira.issue_create.call_args.kwargs["fields"]["labels"]
|
||||||
assert ["aegis", "security-test", "T1003-006"] == labels[:3]
|
assert ["aegis", "T1003-006"] == labels[:2]
|
||||||
if expected_label:
|
if expected_label:
|
||||||
assert expected_label in labels
|
assert expected_label in labels
|
||||||
else:
|
else:
|
||||||
assert len(labels) == 3
|
assert len(labels) == 2
|
||||||
|
|
||||||
|
|
||||||
def _make_user(**overrides):
|
def _make_user(**overrides):
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
|
||||||
const SOURCE_OPTIONS = [
|
const SOURCE_OPTIONS = [
|
||||||
{ value: "", label: "All Sources" },
|
{ value: "", label: "All Sources" },
|
||||||
@@ -89,7 +90,9 @@ export default function TestCatalogPage() {
|
|||||||
const [source, setSource] = useState(searchParams.get("source") || "");
|
const [source, setSource] = useState(searchParams.get("source") || "");
|
||||||
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
||||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||||
|
const [hideCovered, setHideCovered] = useState(searchParams.get("hide_covered") === "1");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
// Build filters
|
// Build filters
|
||||||
const filters = {
|
const filters = {
|
||||||
@@ -97,15 +100,21 @@ export default function TestCatalogPage() {
|
|||||||
source: source || undefined,
|
source: source || undefined,
|
||||||
platform: platform || undefined,
|
platform: platform || undefined,
|
||||||
severity: severity || undefined,
|
severity: severity || undefined,
|
||||||
offset: page * PAGE_SIZE,
|
offset: page * pageSize,
|
||||||
limit: PAGE_SIZE,
|
limit: pageSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: templates = [], isLoading } = useQuery({
|
const { data: allTemplates = [], isLoading } = useQuery({
|
||||||
queryKey: ["test-templates", filters],
|
queryKey: ["test-templates", filters],
|
||||||
queryFn: () => getTemplates(filters),
|
queryFn: () => getTemplates(filters),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "No tests yet" is a client-side filter — existing_test_count is already
|
||||||
|
// returned per-template, no need for a separate backend round-trip.
|
||||||
|
const templates = hideCovered
|
||||||
|
? allTemplates.filter((t) => t.existing_test_count === 0)
|
||||||
|
: allTemplates;
|
||||||
|
|
||||||
// ── Filter handlers ──────────────────────────────────────────────
|
// ── Filter handlers ──────────────────────────────────────────────
|
||||||
|
|
||||||
const applyFilters = () => {
|
const applyFilters = () => {
|
||||||
@@ -115,6 +124,7 @@ export default function TestCatalogPage() {
|
|||||||
if (source) params.set("source", source);
|
if (source) params.set("source", source);
|
||||||
if (platform) params.set("platform", platform);
|
if (platform) params.set("platform", platform);
|
||||||
if (severity) params.set("severity", severity);
|
if (severity) params.set("severity", severity);
|
||||||
|
if (hideCovered) params.set("hide_covered", "1");
|
||||||
setSearchParams(params);
|
setSearchParams(params);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,11 +133,12 @@ export default function TestCatalogPage() {
|
|||||||
setSource("");
|
setSource("");
|
||||||
setPlatform("");
|
setPlatform("");
|
||||||
setSeverity("");
|
setSeverity("");
|
||||||
|
setHideCovered(false);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters = search || source || platform || severity;
|
const hasActiveFilters = search || source || platform || severity || hideCovered;
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -220,6 +231,17 @@ export default function TestCatalogPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* No-coverage-yet toggle */}
|
||||||
|
<label className="mt-3 flex w-fit items-center gap-2 text-sm text-gray-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideCovered}
|
||||||
|
onChange={(e) => { setHideCovered(e.target.checked); setPage(0); }}
|
||||||
|
className="h-4 w-4 rounded border-gray-600 bg-gray-800 text-cyan-500 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
Only show techniques with no existing test
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
@@ -253,11 +275,26 @@ export default function TestCatalogPage() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Showing {page * PAGE_SIZE + 1}
|
Showing {page * pageSize + 1}
|
||||||
{" - "}
|
{" - "}
|
||||||
{page * PAGE_SIZE + templates.length}
|
{page * pageSize + allTemplates.length}
|
||||||
|
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
|
||||||
</p>
|
</p>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-gray-500">
|
||||||
|
Show
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(0); }}
|
||||||
|
className="rounded-lg border border-gray-700 bg-gray-800 px-2 py-1 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
@@ -270,7 +307,7 @@ export default function TestCatalogPage() {
|
|||||||
<span className="text-sm text-gray-400">Page {page + 1}</span>
|
<span className="text-sm text-gray-400">Page {page + 1}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => p + 1)}
|
onClick={() => setPage((p) => p + 1)}
|
||||||
disabled={templates.length < PAGE_SIZE}
|
disabled={allTemplates.length < pageSize}
|
||||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-gray-800 disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
|
|||||||
Reference in New Issue
Block a user