Skip to content

Commit bfd93a1

Browse files
Copilotmaxneuvians
andauthored
Add evidence creation API with flexible linking capabilities (#55)
* Initial plan * Implement Evidence API controller with comprehensive testing Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * Refactor evidence linking logic to Composer module and add comprehensive tests Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * Implement NIST control-based evidence linking functionality Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * Add comprehensive NIST control unit tests for evidence linking Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * Fix get_in/3 undefined function error by using get_in/2 with default operator Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * Fix database insert error handling in evidence linking functions Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * Fix evidence associations not included in JSON response and resolve unused variable warnings Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> * fix: fmt --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: maxneuvians <867334+maxneuvians@users.noreply.github.com> Co-authored-by: Max Neuvians <max@neuvians.net>
1 parent 30cb231 commit bfd93a1

File tree

6 files changed

+1293
-1
lines changed

6 files changed

+1293
-1
lines changed

valentine/lib/valentine/composer.ex

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,4 +1863,207 @@ defmodule Valentine.Composer do
18631863
def change_evidence(%Evidence{} = evidence, attrs \\ %{}) do
18641864
Evidence.changeset(evidence, attrs)
18651865
end
1866+
1867+
@doc """
1868+
Creates evidence with automatic linking based on provided parameters.
1869+
1870+
This function handles the creation of evidence and its automatic linking to
1871+
assumptions, threats, and mitigations based on the provided linking strategy.
1872+
1873+
## Parameters
1874+
- evidence_attrs: Evidence attributes for creation
1875+
- linking_opts: Map containing linking options:
1876+
- assumption_id: Direct link to assumption
1877+
- threat_id: Direct link to threat
1878+
- mitigation_id: Direct link to mitigation
1879+
- use_ai: Boolean flag for AI-based linking (stubbed)
1880+
1881+
## Returns
1882+
{:ok, evidence_with_associations} | {:error, changeset}
1883+
1884+
## Examples
1885+
1886+
iex> create_evidence_with_linking(%{name: "Test", evidence_type: :json_data, content: %{}}, %{assumption_id: "uuid"})
1887+
{:ok, %Evidence{}}
1888+
1889+
"""
1890+
def create_evidence_with_linking(evidence_attrs, linking_opts \\ %{}) do
1891+
case create_evidence(evidence_attrs) do
1892+
{:ok, evidence} ->
1893+
# This would be called by the API controller for centralized linking logic
1894+
linked_evidence = apply_evidence_linking(evidence, linking_opts)
1895+
1896+
evidence_with_associations =
1897+
Repo.preload(linked_evidence, [:assumptions, :threats, :mitigations])
1898+
1899+
{:ok, evidence_with_associations}
1900+
1901+
{:error, changeset} ->
1902+
{:error, changeset}
1903+
end
1904+
end
1905+
1906+
@doc """
1907+
Applies linking logic to evidence based on provided options.
1908+
1909+
## Parameters
1910+
- evidence: The evidence to link
1911+
- linking_opts: Map containing linking strategy options
1912+
1913+
## Returns
1914+
The evidence (linking is done via side effects to join tables)
1915+
"""
1916+
def apply_evidence_linking(evidence, linking_opts) do
1917+
# Direct ID linking takes precedence
1918+
if has_direct_linking_ids?(linking_opts) do
1919+
apply_direct_evidence_linking(evidence, linking_opts)
1920+
else
1921+
# NIST control-based linking when evidence has nist_controls
1922+
apply_nist_control_evidence_linking(evidence, linking_opts)
1923+
end
1924+
end
1925+
1926+
defp has_direct_linking_ids?(linking_opts) do
1927+
Map.get(linking_opts, :assumption_id) ||
1928+
Map.get(linking_opts, :threat_id) ||
1929+
Map.get(linking_opts, :mitigation_id)
1930+
end
1931+
1932+
defp apply_direct_evidence_linking(evidence, linking_opts) do
1933+
if assumption_id = Map.get(linking_opts, :assumption_id) do
1934+
link_evidence_to_assumption_by_id(evidence, assumption_id)
1935+
end
1936+
1937+
if threat_id = Map.get(linking_opts, :threat_id) do
1938+
link_evidence_to_threat_by_id(evidence, threat_id)
1939+
end
1940+
1941+
if mitigation_id = Map.get(linking_opts, :mitigation_id) do
1942+
link_evidence_to_mitigation_by_id(evidence, mitigation_id)
1943+
end
1944+
1945+
evidence
1946+
end
1947+
1948+
defp link_evidence_to_assumption_by_id(evidence, assumption_id) do
1949+
try do
1950+
assumption = get_assumption!(assumption_id)
1951+
1952+
if assumption.workspace_id == evidence.workspace_id do
1953+
case %EvidenceAssumption{evidence_id: evidence.id, assumption_id: assumption.id}
1954+
|> Repo.insert() do
1955+
{:ok, _} -> :ok
1956+
# Ignore duplicates or constraint errors
1957+
{:error, _} -> :ok
1958+
end
1959+
end
1960+
rescue
1961+
Ecto.NoResultsError -> :ok
1962+
end
1963+
end
1964+
1965+
defp link_evidence_to_threat_by_id(evidence, threat_id) do
1966+
try do
1967+
threat = get_threat!(threat_id)
1968+
1969+
if threat.workspace_id == evidence.workspace_id do
1970+
case %EvidenceThreat{evidence_id: evidence.id, threat_id: threat.id}
1971+
|> Repo.insert() do
1972+
{:ok, _} -> :ok
1973+
# Ignore duplicates or constraint errors
1974+
{:error, _} -> :ok
1975+
end
1976+
end
1977+
rescue
1978+
Ecto.NoResultsError -> :ok
1979+
end
1980+
end
1981+
1982+
defp apply_nist_control_evidence_linking(evidence, _linking_opts) do
1983+
# Only proceed if evidence has NIST controls defined
1984+
if evidence.nist_controls && length(evidence.nist_controls) > 0 do
1985+
link_evidence_by_nist_controls(evidence)
1986+
end
1987+
1988+
evidence
1989+
end
1990+
1991+
defp link_evidence_by_nist_controls(evidence) do
1992+
workspace_id = evidence.workspace_id
1993+
nist_controls = evidence.nist_controls
1994+
1995+
# Find assumptions with overlapping NIST controls in tags
1996+
assumptions = find_assumptions_by_nist_tags(workspace_id, nist_controls)
1997+
1998+
Enum.each(assumptions, fn assumption ->
1999+
case %EvidenceAssumption{evidence_id: evidence.id, assumption_id: assumption.id}
2000+
|> Repo.insert() do
2001+
{:ok, _} -> :ok
2002+
# Ignore duplicates or constraint errors
2003+
{:error, _} -> :ok
2004+
end
2005+
end)
2006+
2007+
# Find threats with overlapping NIST controls in tags
2008+
threats = find_threats_by_nist_tags(workspace_id, nist_controls)
2009+
2010+
Enum.each(threats, fn threat ->
2011+
case %EvidenceThreat{evidence_id: evidence.id, threat_id: threat.id}
2012+
|> Repo.insert() do
2013+
{:ok, _} -> :ok
2014+
# Ignore duplicates or constraint errors
2015+
{:error, _} -> :ok
2016+
end
2017+
end)
2018+
2019+
# Find mitigations with overlapping NIST controls in tags
2020+
mitigations = find_mitigations_by_nist_tags(workspace_id, nist_controls)
2021+
2022+
Enum.each(mitigations, fn mitigation ->
2023+
case %EvidenceMitigation{evidence_id: evidence.id, mitigation_id: mitigation.id}
2024+
|> Repo.insert() do
2025+
{:ok, _} -> :ok
2026+
# Ignore duplicates or constraint errors
2027+
{:error, _} -> :ok
2028+
end
2029+
end)
2030+
end
2031+
2032+
defp find_assumptions_by_nist_tags(workspace_id, nist_controls) do
2033+
from(a in Assumption,
2034+
where: a.workspace_id == ^workspace_id and fragment("? && ?", a.tags, ^nist_controls)
2035+
)
2036+
|> Repo.all()
2037+
end
2038+
2039+
defp find_threats_by_nist_tags(workspace_id, nist_controls) do
2040+
from(t in Threat,
2041+
where: t.workspace_id == ^workspace_id and fragment("? && ?", t.tags, ^nist_controls)
2042+
)
2043+
|> Repo.all()
2044+
end
2045+
2046+
defp find_mitigations_by_nist_tags(workspace_id, nist_controls) do
2047+
from(m in Mitigation,
2048+
where: m.workspace_id == ^workspace_id and fragment("? && ?", m.tags, ^nist_controls)
2049+
)
2050+
|> Repo.all()
2051+
end
2052+
2053+
defp link_evidence_to_mitigation_by_id(evidence, mitigation_id) do
2054+
try do
2055+
mitigation = get_mitigation!(mitigation_id)
2056+
2057+
if mitigation.workspace_id == evidence.workspace_id do
2058+
case %EvidenceMitigation{evidence_id: evidence.id, mitigation_id: mitigation.id}
2059+
|> Repo.insert() do
2060+
{:ok, _} -> :ok
2061+
# Ignore duplicates or constraint errors
2062+
{:error, _} -> :ok
2063+
end
2064+
end
2065+
rescue
2066+
Ecto.NoResultsError -> :ok
2067+
end
2068+
end
18662069
end

valentine/lib/valentine/composer/evidence.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ defmodule Valentine.Composer.Evidence do
1616
:content,
1717
:blob_store_url,
1818
:nist_controls,
19-
:tags
19+
:tags,
20+
:assumptions,
21+
:threats,
22+
:mitigations
2023
]}
2124

2225
schema "evidence" do
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule ValentineWeb.Api.EvidenceController do
2+
use ValentineWeb, :controller
3+
4+
alias Valentine.Composer
5+
6+
def create(conn, %{"evidence" => evidence_params} = params) do
7+
api_key = conn.assigns[:api_key]
8+
workspace_id = api_key.workspace_id
9+
10+
# Extract linking parameters
11+
linking_opts = %{
12+
assumption_id: get_in(params, ["linking", "assumption_id"]),
13+
threat_id: get_in(params, ["linking", "threat_id"]),
14+
mitigation_id: get_in(params, ["linking", "mitigation_id"]),
15+
use_ai: get_in(params, ["linking", "use_ai"]) || false
16+
}
17+
18+
# Add workspace_id to evidence params
19+
evidence_attrs = Map.put(evidence_params, "workspace_id", workspace_id)
20+
21+
case Composer.create_evidence_with_linking(evidence_attrs, linking_opts) do
22+
{:ok, evidence_with_associations} ->
23+
conn
24+
|> put_status(:created)
25+
|> json(%{
26+
evidence: evidence_with_associations,
27+
message: "Evidence created successfully"
28+
})
29+
30+
{:error, changeset} ->
31+
conn
32+
|> put_status(:unprocessable_entity)
33+
|> json(%{errors: format_changeset_errors(changeset)})
34+
end
35+
end
36+
37+
defp format_changeset_errors(changeset) do
38+
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
39+
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
40+
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
41+
end)
42+
end)
43+
end
44+
end

valentine/lib/valentine_web/router.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ defmodule ValentineWeb.Router do
3838
pipe_through :api
3939

4040
get "/workspace", WorkspaceController, :index
41+
post "/evidence", EvidenceController, :create
4142
end
4243

4344
scope "/auth", ValentineWeb do

0 commit comments

Comments
 (0)