Skip to content

Commit e125549

Browse files
rainheadclaude
andauthored
fix: authorize seeding at request time, not build time (#1022)
* fix: authorize seeding at request time, not build time Part of #1020. The seed mutations are exposed in the GraphQL schema unconditionally. A validation already rejected them when ENABLE_SEED_FROM_PROD was off, but resource validations only run for create/update/destroy actions, not for generic actions. seed_all maps to the generic :time_range action, which therefore ran: it invoked the feeds create action, read every feed, and only failed partway through on a nested create -- or, with no feeds present, returned {:ok, []} having refused nothing. A policy covers every action type, so seeding is now refused up front. The check reads the flag through Application.get_env/3, so it follows the app serving the request; config.exs is evaluated at boot, since the server runs under Mix rather than as a release. Ash.bulk_create/4 in Seed.Changes.SeedResource passes authorize?: false, so the target resources' own policies never run. Seed is the only place this can be gated. The seed page moves from getStaticProps to getServerSideProps for the same reason: whether it existed was decided by the app the artifact was built in, and one artifact is promoted across all three. Hiding it is presentation only -- the policy is what refuses the work. Tests cover both action types with seeding disabled and enabled. Verified the generic-action gap is real: with the policy removed, :time_range returns {:ok, []} instead of Forbidden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: set the seed flag in runtime.exs so authorization survives a release Under Mix, config.exs is re-evaluated at boot, so Application.get_env/3 already reported the serving app's ENABLE_SEED_FROM_PROD. Under a release it would not: config.exs is frozen into the artifact and only runtime.exs runs at startup, so the authorization check would have read the value from whichever environment built the artifact -- the exact failure this change exists to prevent, and a live risk if the app is ever packaged as a release (a Dockerfile deploy on fly.io would be). Setting the key in runtime.exs makes the request-time reads correct under both models. config.exs keeps its copy for the Application.compile_env/3 calls that decide whether the seed actions are compiled in at all. Under a release the two copies disagreeing becomes a boot-time error rather than a silent authorization, which is the outcome worth having until the compile-time gating is removed altogether. Raised by CodeRabbit on #1022. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent bad5c69 commit e125549

5 files changed

Lines changed: 136 additions & 2 deletions

File tree

server/config/runtime.exs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ end
2323

2424
config :orcasite, :prod_host, System.get_env("PROD_HOST_URL", "live.orcasound.net")
2525

26+
# Also set in config.exs, which Application.compile_env/3 reads to decide whether
27+
# the seed actions are compiled in at all. Repeating it here is redundant while
28+
# the server runs under Mix, since config.exs is re-evaluated at boot -- but it
29+
# would not be under a release, where config.exs is frozen into the artifact and
30+
# only runtime.exs runs at startup. Orcasite.Radio.Checks.SeedFromProdEnabled and
31+
# the validation in Orcasite.Radio.Seed authorize on this value, so it has to
32+
# describe the app serving the request under either model.
33+
config :orcasite,
34+
enable_seed_from_prod: System.get_env("ENABLE_SEED_FROM_PROD", "false") == "true"
35+
2636
if config_env() == :prod do
2737
database_url =
2838
System.get_env("DATABASE_URL") ||
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
defmodule Orcasite.Radio.Checks.SeedFromProdEnabled do
2+
@moduledoc """
3+
Passes only where seeding from prod is turned on for the running app.
4+
5+
Read at request time rather than compile time. Deploys build one artifact and
6+
promote it across apps, so a compile-time answer describes the app the
7+
artifact was built in rather than the app serving the request.
8+
9+
The value is set in `config/runtime.exs`, which runs at boot under both Mix
10+
and a release. `config.exs` sets it too, for the `Application.compile_env/3`
11+
calls that decide whether the seed actions exist at all -- but that copy is
12+
frozen into the artifact under a release, so it must not be what authorizes a
13+
request.
14+
"""
15+
use Ash.Policy.SimpleCheck
16+
17+
@impl true
18+
def describe(_opts), do: "seeding from prod is enabled for this environment"
19+
20+
@impl true
21+
def match?(_actor, _context, _opts) do
22+
Application.get_env(:orcasite, :enable_seed_from_prod, false) == true
23+
end
24+
end

server/lib/orcasite/radio/seed.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
defmodule Orcasite.Radio.Seed do
22
use Ash.Resource,
33
domain: Orcasite.Radio,
4+
authorizers: [Ash.Policy.Authorizer],
45
extensions: [AshGraphql.Resource, AshOban]
56

67
resource do
78
description "Non-persisted resource to seed records from specific time ranges from Orcasite prod"
89
end
910

11+
# These mutations are exposed in the GraphQL schema unconditionally, and the
12+
# changes behind them call Ash.bulk_create/4 with authorize?: false, so the
13+
# target resources' own policies never run. This is the only place seeding can
14+
# be gated, and without it the seed mutations are reachable by anyone.
15+
#
16+
# Checked at request time so the answer follows the app serving the request
17+
# rather than the app the artifact was built in. The compile-time flags
18+
# elsewhere decide whether the seed actions exist at all; this decides whether
19+
# they may be invoked here and now.
20+
policies do
21+
policy always() do
22+
authorize_if Orcasite.Radio.Checks.SeedFromProdEnabled
23+
end
24+
end
25+
1026
attributes do
1127
uuid_primary_key :id
1228

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
defmodule Orcasite.Radio.SeedTest do
2+
@moduledoc """
3+
Tests for https://github.com/orcasound/orcasite/issues/1020.
4+
5+
The seed mutations are exposed in the GraphQL schema unconditionally. A
6+
validation in `Orcasite.Radio.Seed` already rejects them when
7+
`ENABLE_SEED_FROM_PROD` is off, but resource validations only run for
8+
create/update/destroy actions -- not for generic actions. `seed_all` maps to
9+
the generic `:time_range` action, so it reached its `run` block and did work
10+
before failing partway through on a nested create.
11+
12+
A policy now covers every action type, so seeding is refused up front.
13+
"""
14+
15+
use Orcasite.DataCase, async: false
16+
17+
alias Orcasite.Radio.Seed
18+
19+
setup do
20+
original = Application.get_env(:orcasite, :enable_seed_from_prod, false)
21+
on_exit(fn -> Application.put_env(:orcasite, :enable_seed_from_prod, original) end)
22+
:ok
23+
end
24+
25+
defp set_seeding(enabled?), do: Application.put_env(:orcasite, :enable_seed_from_prod, enabled?)
26+
27+
describe "with seeding disabled" do
28+
setup do
29+
set_seeding(false)
30+
:ok
31+
end
32+
33+
test "the generic time_range action is forbidden outright" do
34+
assert {:error, %Ash.Error.Forbidden{}} = run_time_range()
35+
end
36+
37+
test "creating feeds is rejected" do
38+
assert {:error, error} = Ash.create(Seed, %{}, action: :feeds)
39+
assert error_message(error) =~ "Seeding is disabled"
40+
end
41+
42+
test "seeding a resource is rejected" do
43+
assert {:error, error} =
44+
Ash.create(
45+
Seed,
46+
%{
47+
resource: :detection,
48+
feed_id: "whatever",
49+
start_time: DateTime.utc_now(),
50+
end_time: DateTime.utc_now()
51+
},
52+
action: :resource
53+
)
54+
55+
assert error_message(error) =~ "Seeding is disabled"
56+
end
57+
end
58+
59+
describe "with seeding enabled" do
60+
setup do
61+
set_seeding(true)
62+
:ok
63+
end
64+
65+
test "authorization no longer refuses the generic time_range action" do
66+
# Not asserting success -- that would reach the prod GraphQL API. Only
67+
# that the policy is no longer what stops it.
68+
refute match?({:error, %Ash.Error.Forbidden{}}, run_time_range())
69+
end
70+
end
71+
72+
defp run_time_range do
73+
Seed
74+
|> Ash.ActionInput.for_action(:time_range, %{})
75+
|> Ash.run_action()
76+
end
77+
78+
defp error_message(error), do: Exception.message(error)
79+
end

ui/src/pages/seed.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,14 @@ function toLocalISOString(date: Date) {
310310

311311
SeedPage.getLayout = getSimpleLayout;
312312

313-
export async function getStaticProps() {
313+
// Per request, not at build time. getStaticProps ran during the build, so
314+
// whether this page existed was decided by the app the artifact was built in
315+
// rather than the app serving the request -- and one artifact is promoted
316+
// across dev, staging and production. Hiding the page is presentation only;
317+
// the seed actions are refused server-side by Orcasite.Radio.Seed's policy.
318+
export async function getServerSideProps() {
314319
const enableSeedFromProd = process.env.ENABLE_SEED_FROM_PROD === "true";
315-
// Hide the seed page when `ENABLE_SEED_FROM_PROD` isn't enabled
320+
316321
return !enableSeedFromProd ? { notFound: true } : { props: {} };
317322
}
318323

0 commit comments

Comments
 (0)