Skip to content

Commit 795deb4

Browse files
authored
Dependency policies (#190)
* Add Policy resource support to registry and repo modules Generates hex_pb_policy.erl from the new policy.proto in specifications. hex_registry gains encode_policy/decode_policy/ build_policy/unpack_policy; hex_repo gains get_policy/2 for fetching /repos/<org>/policies/<name>. Round-trip and HTTP fixture tests cover signed payload verification, repository/name mismatch errors, and the no_verify path. * Require repo_organization in hex_repo:get_policy/2 Policies only exist per-organization, so calling get_policy/2 without repo_organization set was silently falling back to the global path and returning a misleading bad_repo_name. Raise a clear error instead. Extend tests to cover the new error path and the no_verify unpack path. * Tighten Policy decoders and missing-org error idiom decode_policy/3 now matches published_at as a shape witness, matching the pattern that decode_package/3 (releases) and decode_names/2 / decode_versions/2 (packages) use to detect corrupt or mis-routed payloads. get_policy/2 returns {error, missing_repo_organization} when repo_organization is unset, matching the tagged-tuple error idiom the rest of the module uses. The embedded English string and the ad-hoc error/1 raise are dropped. * Remove Policy.published_at — unused by any client Mirrors the change in specifications. Field 4 is reserved on the wire. The decode_policy/3 shape witness moves from published_at to visibility (also required). Fixtures and tests updated. * Renumber Policy fields contiguously after dropping published_at Mirrors the renumbering in specifications. The proto was unreleased so back-compat reservations are dead weight. * Use bound Org binding in get_policy/2 decoder The Org binding from the case clause is equivalent to repo_name(Config) inside this branch (repo_name/1 returns repo_organization when set), so use it directly and drop the indirection. * Drop tautological visibility witness in decode_policy/3 visibility is required by the proto, so the pattern always binds it. Keep only the repository/name checks that match against the verifier inputs. * Drop no-op maps:remove in get_policy_missing_org_test ?CONFIG never sets repo_organization, so removing it was misleading. Use ?CONFIG directly — the absence of the key is what the test exercises. * Document missing_repo_organization error in get_policy/2 doc Mention the {error, missing_repo_organization} return so the doc covers both the success and absence-of-config cases.
1 parent a20ef0e commit 795deb4

7 files changed

Lines changed: 958 additions & 1 deletion

File tree

proto/hex_pb_policy.proto

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
syntax = "proto2";
2+
3+
message Policy {
4+
// Name of repository
5+
required string repository = 1;
6+
7+
// Policy name within the repository
8+
// (matches ^[a-z0-9][a-z0-9_\-\.]*[a-z0-9]$, length 3..64)
9+
required string name = 2;
10+
11+
// Optional, free-form description (admin-set, surfaced in CLI/UI)
12+
optional string description = 3;
13+
14+
// Whether the policy is publicly readable or restricted to org members.
15+
// Read at the edge to decide whether to enforce auth on the fetch.
16+
// Adding new Visibility values is a breaking change — old clients will
17+
// treat unknown values as PRIVATE per the fail-closed rule.
18+
required Visibility visibility = 4;
19+
20+
// Categorical advisory rule. If set, deny any release whose maximum
21+
// advisory severity is at least this value. Values map to AdvisorySeverity
22+
// in package.proto (SEVERITY_NONE..SEVERITY_CRITICAL = 0..4).
23+
// Unset = rule disabled.
24+
optional uint32 advisory_min_severity = 5;
25+
26+
// Categorical retirement rule. If non-empty, deny any release retired with
27+
// a reason in this set. Values map to RetirementReason in package.proto
28+
// (RETIRED_OTHER..RETIRED_RENAMED = 0..4). Empty = rule disabled.
29+
repeated uint32 retirement_reasons = 6 [packed=true];
30+
31+
// Optional minimum release age for every package version governed by this
32+
// policy. Same duration grammar as the Hex cooldown config ("7d", "2w",
33+
// "1mo", "0"). Unset or "0" means no policy cooldown. If multiple active
34+
// policies declare cooldowns, the effective cooldown is the strictest one.
35+
optional string cooldown = 7;
36+
}
37+
38+
enum Visibility {
39+
// PRIVATE is the safe default; unknown enum values must be treated as PRIVATE.
40+
VISIBILITY_PRIVATE = 0;
41+
VISIBILITY_PUBLIC = 1;
42+
}

src/hex_pb_policy.erl

Lines changed: 775 additions & 0 deletions
Large diffs are not rendered by default.

src/hex_registry.erl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
decode_package/3,
1515
build_package/2,
1616
unpack_package/4,
17+
encode_policy/1,
18+
decode_policy/3,
19+
build_policy/2,
20+
unpack_policy/4,
1721
sign_protobuf/2,
1822
decode_signed/1,
1923
decode_and_verify_signed/2,
@@ -116,6 +120,35 @@ decode_package(Payload, Repository, Package) ->
116120
{error, bad_repo_name}
117121
end.
118122

123+
%% @doc
124+
%% Builds policy resource.
125+
build_policy(Policy, PrivateKey) ->
126+
Payload = encode_policy(Policy),
127+
zlib:gzip(sign_protobuf(Payload, PrivateKey)).
128+
129+
%% @doc
130+
%% Unpacks policy resource.
131+
unpack_policy(Payload, Repository, Name, PublicKey) ->
132+
case decode_and_verify_signed(zlib:gunzip(Payload), PublicKey) of
133+
{ok, Policy} -> decode_policy(Policy, Repository, Name);
134+
Other -> Other
135+
end.
136+
137+
%% @private
138+
encode_policy(Policy) ->
139+
hex_pb_policy:encode_msg(Policy, 'Policy').
140+
141+
%% @private
142+
decode_policy(Payload, no_verify, no_verify) ->
143+
{ok, hex_pb_policy:decode_msg(Payload, 'Policy')};
144+
decode_policy(Payload, Repository, Name) ->
145+
case hex_pb_policy:decode_msg(Payload, 'Policy') of
146+
#{repository := Repository, name := Name} = Result ->
147+
{ok, Result};
148+
_ ->
149+
{error, bad_repo_name}
150+
end.
151+
119152
%% @private
120153
sign_protobuf(Payload, PrivateKey) ->
121154
Signature = sign(Payload, PrivateKey),

src/hex_repo.erl

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
get_names/1,
66
get_versions/1,
77
get_package/2,
8+
get_policy/2,
89
get_tarball/3,
910
get_tarball_to_file/4,
1011
get_docs/3,
@@ -88,6 +89,40 @@ get_package(Config, Name) when is_binary(Name) and is_map(Config) ->
8889
end,
8990
get_protobuf(Config, <<"packages/", Name/binary>>, Decoder).
9091

92+
%% @doc
93+
%% Gets policy resource from the repository.
94+
%%
95+
%% Requires `repo_organization' to be set in the config; policies are
96+
%% always served from the per-organization namespace
97+
%% (`/repos/<organization>/policies/<name>'). Returns
98+
%% `{error, missing_repo_organization}' when it is not set.
99+
%%
100+
%% Examples:
101+
%%
102+
%% ```
103+
%% > Config = (hex_core:default_config())#{repo_organization => <<"myorg">>},
104+
%% > hex_repo:get_policy(Config, <<"strict-prod">>).
105+
%% {ok, {200, ...,
106+
%% #{repository => <<"myorg">>,
107+
%% name => <<"strict-prod">>,
108+
%% visibility => 'VISIBILITY_PUBLIC'}}}
109+
%% '''
110+
%% @end
111+
get_policy(Config, Name) when is_binary(Name) and is_map(Config) ->
112+
case maps:get(repo_organization, Config, undefined) of
113+
undefined ->
114+
{error, missing_repo_organization};
115+
Org when is_binary(Org) ->
116+
Verify = maps:get(repo_verify_origin, Config, true),
117+
Decoder = fun(Data) ->
118+
case Verify of
119+
true -> hex_registry:decode_policy(Data, Org, Name);
120+
false -> hex_registry:decode_policy(Data, no_verify, no_verify)
121+
end
122+
end,
123+
get_protobuf(Config, <<"policies/", Name/binary>>, Decoder)
124+
end.
125+
91126
%% @doc
92127
%% Gets tarball from the repository.
93128
%%

test/hex_registry_SUITE.erl

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ suite() ->
1010
[{require, {ssl_certs, [test_pub, test_priv, hexpm_pub]}}].
1111

1212
all() ->
13-
[names_test, versions_test, package_test, signed_test].
13+
[names_test, versions_test, package_test, policy_test, signed_test].
1414

1515
names_test(_Config) ->
1616
TestPublicKey = ct:get_config({ssl_certs, test_pub}),
@@ -126,6 +126,38 @@ package_test(_Config) ->
126126
),
127127
ok.
128128

129+
policy_test(_Config) ->
130+
TestPublicKey = ct:get_config({ssl_certs, test_pub}),
131+
TestPrivateKey = ct:get_config({ssl_certs, test_priv}),
132+
Policy = #{
133+
repository => <<"myorg">>,
134+
name => <<"strict-prod">>,
135+
description => <<"Production policy">>,
136+
visibility => 'VISIBILITY_PUBLIC',
137+
advisory_min_severity => 3,
138+
retirement_reasons => [1, 2],
139+
cooldown => <<"14d">>
140+
},
141+
Payload = hex_registry:build_policy(Policy, TestPrivateKey),
142+
?assertMatch(
143+
{ok, Policy},
144+
hex_registry:unpack_policy(Payload, <<"myorg">>, <<"strict-prod">>, TestPublicKey)
145+
),
146+
?assertMatch(
147+
{error, bad_repo_name},
148+
hex_registry:unpack_policy(Payload, <<"other">>, <<"strict-prod">>, TestPublicKey)
149+
),
150+
?assertMatch(
151+
{error, bad_repo_name},
152+
hex_registry:unpack_policy(Payload, <<"myorg">>, <<"other">>, TestPublicKey)
153+
),
154+
EncodedPayload = hex_registry:encode_policy(Policy),
155+
?assertMatch({ok, Policy}, hex_registry:decode_policy(EncodedPayload, no_verify, no_verify)),
156+
%% unpack while skipping repo/name check; signature still verified
157+
{ok, _} =
158+
hex_registry:unpack_policy(Payload, no_verify, no_verify, TestPublicKey),
159+
ok.
160+
129161
signed_test(_Config) ->
130162
TestPublicKey = ct:get_config({ssl_certs, test_pub}),
131163
TestPrivateKey = ct:get_config({ssl_certs, test_priv}),

test/hex_repo_SUITE.erl

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ all() ->
2323
get_names_test,
2424
get_versions_test,
2525
get_package_test,
26+
get_policy_test,
27+
get_policy_missing_org_test,
2628
get_tarball_test,
2729
get_tarball_to_file_test,
2830
get_docs_test,
@@ -60,6 +62,26 @@ get_package_test(_Config) ->
6062
{ok, {403, _, _}} = hex_repo:get_package(?CONFIG, <<"nonexisting">>),
6163
ok.
6264

65+
get_policy_test(_Config) ->
66+
Config = maps:put(repo_organization, <<"myorg">>, ?CONFIG),
67+
68+
{ok,
69+
{200, _, #{
70+
repository := <<"myorg">>,
71+
name := <<"strict-prod">>,
72+
visibility := 'VISIBILITY_PUBLIC',
73+
advisory_min_severity := 3,
74+
retirement_reasons := [1, 2],
75+
cooldown := <<"14d">>
76+
}}} = hex_repo:get_policy(Config, <<"strict-prod">>),
77+
78+
{ok, {403, _, _}} = hex_repo:get_policy(Config, <<"nonexisting">>),
79+
ok.
80+
81+
get_policy_missing_org_test(_Config) ->
82+
{error, missing_repo_organization} = hex_repo:get_policy(?CONFIG, <<"strict-prod">>),
83+
ok.
84+
6385
get_tarball_test(_Config) ->
6486
{ok, {200, #{<<"etag">> := ETag}, Tarball}} = hex_repo:get_tarball(
6587
?CONFIG, <<"ecto">>, <<"1.0.0">>

test/support/hex_http_test.erl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ fixture(get, <<?TEST_REPO_URL, "/packages/ecto">>, _, _) ->
9292
},
9393
{ok, {200, Headers, Compressed}};
9494

95+
fixture(get, <<?TEST_REPO_URL, "/repos/myorg/policies/strict-prod">>, _, _) ->
96+
Policy = #{
97+
repository => <<"myorg">>,
98+
name => <<"strict-prod">>,
99+
description => <<"Production policy">>,
100+
visibility => 'VISIBILITY_PUBLIC',
101+
advisory_min_severity => 3,
102+
retirement_reasons => [1, 2],
103+
cooldown => <<"14d">>
104+
},
105+
Payload = hex_registry:encode_policy(Policy),
106+
Signed = hex_registry:sign_protobuf(Payload, ?PRIVATE_KEY),
107+
Compressed = zlib:gzip(Signed),
108+
Headers = #{
109+
<<"etag">> => <<"\"dummy\"">>
110+
},
111+
{ok, {200, Headers, Compressed}};
112+
95113
fixture(get, <<?TEST_REPO_URL, "/tarballs/ecto-1.0.0.tar">>, _, _) ->
96114
Headers = #{
97115
<<"etag">> => <<"\"dummy\"">>

0 commit comments

Comments
 (0)