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 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.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
@@ -285,11 +285,13 @@ _JIRA_KEYS = {
|
||||
@router.get("/jira-config", response_model=JiraConfigOut)
|
||||
def get_jira_config(
|
||||
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).
|
||||
|
||||
**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 (
|
||||
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"
|
||||
severity = _technique_severity(technique).capitalize()
|
||||
labels = ["aegis", "security-test", mitre_id.replace(".", "-")]
|
||||
labels = ["aegis", mitre_id.replace(".", "-")]
|
||||
if test.platform:
|
||||
# Jira labels can't contain whitespace — normalize to kebab-case.
|
||||
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)
|
||||
|
||||
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:
|
||||
assert expected_label in labels
|
||||
else:
|
||||
assert len(labels) == 3
|
||||
assert len(labels) == 2
|
||||
|
||||
|
||||
def _make_user(**overrides):
|
||||
|
||||
@@ -19,7 +19,8 @@ import { useAuth } from "../context/AuthContext";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: "", label: "All Sources" },
|
||||
@@ -89,7 +90,9 @@ export default function TestCatalogPage() {
|
||||
const [source, setSource] = useState(searchParams.get("source") || "");
|
||||
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
||||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||||
const [hideCovered, setHideCovered] = useState(searchParams.get("hide_covered") === "1");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
|
||||
// Build filters
|
||||
const filters = {
|
||||
@@ -97,15 +100,21 @@ export default function TestCatalogPage() {
|
||||
source: source || undefined,
|
||||
platform: platform || undefined,
|
||||
severity: severity || undefined,
|
||||
offset: page * PAGE_SIZE,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * pageSize,
|
||||
limit: pageSize,
|
||||
};
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
const { data: allTemplates = [], isLoading } = useQuery({
|
||||
queryKey: ["test-templates", 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 ──────────────────────────────────────────────
|
||||
|
||||
const applyFilters = () => {
|
||||
@@ -115,6 +124,7 @@ export default function TestCatalogPage() {
|
||||
if (source) params.set("source", source);
|
||||
if (platform) params.set("platform", platform);
|
||||
if (severity) params.set("severity", severity);
|
||||
if (hideCovered) params.set("hide_covered", "1");
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
@@ -123,11 +133,12 @@ export default function TestCatalogPage() {
|
||||
setSource("");
|
||||
setPlatform("");
|
||||
setSeverity("");
|
||||
setHideCovered(false);
|
||||
setPage(0);
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const hasActiveFilters = search || source || platform || severity;
|
||||
const hasActiveFilters = search || source || platform || severity || hideCovered;
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -220,6 +231,17 @@ export default function TestCatalogPage() {
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Results */}
|
||||
@@ -253,11 +275,26 @@ export default function TestCatalogPage() {
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {page * PAGE_SIZE + 1}
|
||||
{" - "}
|
||||
{page * PAGE_SIZE + templates.length}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {page * pageSize + 1}
|
||||
{" - "}
|
||||
{page * pageSize + allTemplates.length}
|
||||
{hideCovered && templates.length !== allTemplates.length && ` (${templates.length} without existing tests)`}
|
||||
</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">
|
||||
<button
|
||||
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>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Next
|
||||
|
||||
Reference in New Issue
Block a user