Skip to content

Commit 92ac072

Browse files
feat: add GraphQL API with Absinthe at /api/v1/graphql
Wire up a full GraphQL endpoint covering all queries and mutations from the existing blueprint schema. Includes Absinthe types, resolvers, auth scope middleware, and tests for queries, mutations, scoping, and errors.
1 parent 3b9aaad commit 92ac072

23 files changed

Lines changed: 882 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
defmodule SentinelCpWeb.GraphQL.Context do
2+
@moduledoc """
3+
Plug that transfers conn assigns into the Absinthe context.
4+
"""
5+
@behaviour Plug
6+
7+
@impl true
8+
def init(opts), do: opts
9+
10+
@impl true
11+
def call(conn, _opts) do
12+
context = %{current_api_key: conn.assigns[:current_api_key]}
13+
Absinthe.Plug.put_options(conn, context: context)
14+
end
15+
end
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
defmodule SentinelCpWeb.GraphQL.Middleware.AuthScope do
2+
@moduledoc """
3+
Absinthe middleware that enforces API key scope checks on GraphQL fields.
4+
"""
5+
@behaviour Absinthe.Middleware
6+
7+
@scope_map %{
8+
# Queries
9+
project: "services:read",
10+
projects: "services:read",
11+
services: "services:read",
12+
alert_rules: "services:read",
13+
slos: "services:read",
14+
policies: "services:read",
15+
nodes: "nodes:read",
16+
bundles: "bundles:read",
17+
rollouts: "rollouts:read",
18+
# Mutations
19+
create_bundle: "bundles:write",
20+
create_rollout: "rollouts:write",
21+
pause_rollout: "rollouts:write",
22+
resume_rollout: "rollouts:write"
23+
}
24+
25+
@impl true
26+
def call(%{context: context} = resolution, _config) do
27+
api_key = context[:current_api_key]
28+
field = resolution.definition.schema_node.identifier
29+
30+
required_scope = Map.get(@scope_map, field)
31+
32+
cond do
33+
# No scope mapping means no restriction
34+
is_nil(required_scope) ->
35+
resolution
36+
37+
# Legacy keys with empty scopes get full access
38+
api_key && api_key.scopes == [] ->
39+
resolution
40+
41+
# Check if the key has the required scope
42+
api_key && required_scope in api_key.scopes ->
43+
resolution
44+
45+
true ->
46+
Absinthe.Resolution.put_result(
47+
resolution,
48+
{:error, "Insufficient scope. Required: #{required_scope}"}
49+
)
50+
end
51+
end
52+
end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Bundles do
2+
@moduledoc false
3+
alias SentinelCp.Audit
4+
alias SentinelCp.Bundles
5+
6+
def list(_parent, %{project_id: project_id} = args, _resolution) do
7+
opts = if args[:limit], do: [limit: args[:limit]], else: []
8+
{:ok, Bundles.list_bundles(project_id, opts)}
9+
end
10+
11+
def list_for_project(project, args, _resolution) do
12+
opts = if args[:limit], do: [limit: args[:limit]], else: []
13+
{:ok, Bundles.list_bundles(project.id, opts)}
14+
end
15+
16+
def create(_parent, %{input: input}, %{context: context}) do
17+
case Bundles.create_bundle(input) do
18+
{:ok, bundle} ->
19+
if api_key = context[:current_api_key] do
20+
Audit.log_api_key_action(api_key, "create", "bundle", bundle.id,
21+
project_id: bundle.project_id
22+
)
23+
end
24+
25+
{:ok, bundle}
26+
27+
{:error, %Ecto.Changeset{} = changeset} ->
28+
{:error, format_errors(changeset)}
29+
30+
{:error, reason} ->
31+
{:error, to_string(reason)}
32+
end
33+
end
34+
35+
defp format_errors(changeset) do
36+
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
37+
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
38+
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
39+
end)
40+
end)
41+
|> Enum.map_join("; ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end)
42+
end
43+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Nodes do
2+
@moduledoc false
3+
alias SentinelCp.Nodes
4+
5+
def list(_parent, %{project_id: project_id}, _resolution) do
6+
{:ok, Nodes.list_nodes(project_id)}
7+
end
8+
9+
def list_for_project(project, _args, _resolution) do
10+
{:ok, Nodes.list_nodes(project.id)}
11+
end
12+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Observability do
2+
@moduledoc false
3+
alias SentinelCp.Observability
4+
5+
def list_alert_rules(_parent, %{project_id: project_id}, _resolution) do
6+
{:ok, Observability.list_alert_rules(project_id)}
7+
end
8+
9+
def list_slos(_parent, %{project_id: project_id}, _resolution) do
10+
{:ok, Observability.list_slos(project_id)}
11+
end
12+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Policies do
2+
@moduledoc false
3+
alias SentinelCp.Policies
4+
5+
def list(_parent, %{project_id: project_id}, _resolution) do
6+
{:ok, Policies.list_policies(project_id)}
7+
end
8+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Projects do
2+
@moduledoc false
3+
alias SentinelCp.Projects
4+
5+
def get(_parent, %{id: id}, _resolution) do
6+
case Projects.get_project(id) do
7+
nil -> {:error, "Project not found"}
8+
project -> {:ok, project}
9+
end
10+
end
11+
12+
def get(_parent, %{slug: slug}, _resolution) do
13+
case Projects.get_project_by_slug(slug) do
14+
nil -> {:error, "Project not found"}
15+
project -> {:ok, project}
16+
end
17+
end
18+
19+
def get(_parent, _args, _resolution) do
20+
{:error, "Either id or slug is required"}
21+
end
22+
23+
def list(_parent, args, _resolution) do
24+
opts = if args[:org_id], do: [org_id: args[:org_id]], else: []
25+
{:ok, Projects.list_projects(opts)}
26+
end
27+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Rollouts do
2+
@moduledoc false
3+
alias SentinelCp.Audit
4+
alias SentinelCp.Rollouts
5+
6+
def list(_parent, %{project_id: project_id}, _resolution) do
7+
{:ok, Rollouts.list_rollouts(project_id)}
8+
end
9+
10+
def list_for_project(project, _args, _resolution) do
11+
{:ok, Rollouts.list_rollouts(project.id)}
12+
end
13+
14+
def create(_parent, %{input: input}, %{context: context}) do
15+
case Rollouts.create_rollout(input) do
16+
{:ok, rollout} ->
17+
audit(context, "create", "rollout", rollout.id, rollout.project_id)
18+
{:ok, rollout}
19+
20+
{:error, %Ecto.Changeset{} = changeset} ->
21+
{:error, format_errors(changeset)}
22+
23+
{:error, reason} ->
24+
{:error, to_string(reason)}
25+
end
26+
end
27+
28+
def pause(_parent, %{id: id}, %{context: context}) do
29+
with_rollout(id, fn rollout ->
30+
case Rollouts.pause_rollout(rollout) do
31+
{:ok, updated} ->
32+
audit(context, "pause", "rollout", id, rollout.project_id)
33+
{:ok, updated}
34+
35+
{:error, reason} ->
36+
{:error, to_string(reason)}
37+
end
38+
end)
39+
end
40+
41+
def resume(_parent, %{id: id}, %{context: context}) do
42+
with_rollout(id, fn rollout ->
43+
case Rollouts.resume_rollout(rollout) do
44+
{:ok, updated} ->
45+
audit(context, "resume", "rollout", id, rollout.project_id)
46+
{:ok, updated}
47+
48+
{:error, reason} ->
49+
{:error, to_string(reason)}
50+
end
51+
end)
52+
end
53+
54+
def resolve_progress(rollout, _args, _resolution) do
55+
{:ok, Rollouts.get_rollout_progress(rollout.id)}
56+
end
57+
58+
defp with_rollout(id, fun) do
59+
case Rollouts.get_rollout(id) do
60+
nil -> {:error, "Rollout not found"}
61+
rollout -> fun.(rollout)
62+
end
63+
end
64+
65+
defp audit(context, action, resource_type, resource_id, project_id) do
66+
if api_key = context[:current_api_key] do
67+
Audit.log_api_key_action(api_key, action, resource_type, resource_id,
68+
project_id: project_id
69+
)
70+
end
71+
end
72+
73+
defp format_errors(changeset) do
74+
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
75+
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
76+
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
77+
end)
78+
end)
79+
|> Enum.map_join("; ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end)
80+
end
81+
end
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule SentinelCpWeb.GraphQL.Resolvers.Services do
2+
@moduledoc false
3+
alias SentinelCp.Services
4+
5+
def list(_parent, %{project_id: project_id}, _resolution) do
6+
{:ok, Services.list_services(project_id)}
7+
end
8+
9+
def list_for_project(project, _args, _resolution) do
10+
{:ok, Services.list_services(project.id)}
11+
end
12+
end
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule SentinelCpWeb.GraphQL.Schema do
2+
@moduledoc false
3+
use Absinthe.Schema
4+
5+
import_types(SentinelCpWeb.GraphQL.Types.CustomScalars)
6+
import_types(SentinelCpWeb.GraphQL.Types.Project)
7+
import_types(SentinelCpWeb.GraphQL.Types.Service)
8+
import_types(SentinelCpWeb.GraphQL.Types.Node)
9+
import_types(SentinelCpWeb.GraphQL.Types.Bundle)
10+
import_types(SentinelCpWeb.GraphQL.Types.Rollout)
11+
import_types(SentinelCpWeb.GraphQL.Types.Observability)
12+
import_types(SentinelCpWeb.GraphQL.Types.Policy)
13+
14+
query do
15+
import_fields(:project_queries)
16+
import_fields(:service_queries)
17+
import_fields(:node_queries)
18+
import_fields(:bundle_queries)
19+
import_fields(:rollout_queries)
20+
import_fields(:observability_queries)
21+
import_fields(:policy_queries)
22+
end
23+
24+
mutation do
25+
import_fields(:bundle_mutations)
26+
import_fields(:rollout_mutations)
27+
end
28+
29+
def middleware(middleware, _field, %Absinthe.Type.Object{identifier: identifier})
30+
when identifier in [:query, :mutation] do
31+
[SentinelCpWeb.GraphQL.Middleware.AuthScope | middleware]
32+
end
33+
34+
def middleware(middleware, _field, _object), do: middleware
35+
end

0 commit comments

Comments
 (0)