Skip to content
42 changes: 42 additions & 0 deletions proto/hex_pb_policy.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
syntax = "proto2";

message Policy {
// Name of repository
required string repository = 1;

// Policy name within the repository
// (matches ^[a-z0-9][a-z0-9_\-\.]*[a-z0-9]$, length 3..64)
required string name = 2;

// Optional, free-form description (admin-set, surfaced in CLI/UI)
optional string description = 3;

// Whether the policy is publicly readable or restricted to org members.
// Read at the edge to decide whether to enforce auth on the fetch.
// Adding new Visibility values is a breaking change — old clients will
// treat unknown values as PRIVATE per the fail-closed rule.
required Visibility visibility = 4;

// Categorical advisory rule. If set, deny any release whose maximum
// advisory severity is at least this value. Values map to AdvisorySeverity
// in package.proto (SEVERITY_NONE..SEVERITY_CRITICAL = 0..4).
// Unset = rule disabled.
optional uint32 advisory_min_severity = 5;

// Categorical retirement rule. If non-empty, deny any release retired with
// a reason in this set. Values map to RetirementReason in package.proto
// (RETIRED_OTHER..RETIRED_RENAMED = 0..4). Empty = rule disabled.
repeated uint32 retirement_reasons = 6 [packed=true];

// Optional minimum release age for every package version governed by this
// policy. Same duration grammar as the Hex cooldown config ("7d", "2w",
// "1mo", "0"). Unset or "0" means no policy cooldown. If multiple active
// policies declare cooldowns, the effective cooldown is the strictest one.
optional string cooldown = 7;
}

enum Visibility {
// PRIVATE is the safe default; unknown enum values must be treated as PRIVATE.
VISIBILITY_PRIVATE = 0;
VISIBILITY_PUBLIC = 1;
}
775 changes: 775 additions & 0 deletions src/hex_pb_policy.erl

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions src/hex_registry.erl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
decode_package/3,
build_package/2,
unpack_package/4,
encode_policy/1,
decode_policy/3,
build_policy/2,
unpack_policy/4,
sign_protobuf/2,
decode_signed/1,
decode_and_verify_signed/2,
Expand Down Expand Up @@ -116,6 +120,35 @@ decode_package(Payload, Repository, Package) ->
{error, bad_repo_name}
end.

%% @doc
%% Builds policy resource.
build_policy(Policy, PrivateKey) ->
Payload = encode_policy(Policy),
zlib:gzip(sign_protobuf(Payload, PrivateKey)).

%% @doc
%% Unpacks policy resource.
unpack_policy(Payload, Repository, Name, PublicKey) ->
case decode_and_verify_signed(zlib:gunzip(Payload), PublicKey) of
{ok, Policy} -> decode_policy(Policy, Repository, Name);
Other -> Other
end.

%% @private
encode_policy(Policy) ->
hex_pb_policy:encode_msg(Policy, 'Policy').

%% @private
decode_policy(Payload, no_verify, no_verify) ->
{ok, hex_pb_policy:decode_msg(Payload, 'Policy')};
decode_policy(Payload, Repository, Name) ->
case hex_pb_policy:decode_msg(Payload, 'Policy') of
#{repository := Repository, name := Name} = Result ->
{ok, Result};
_ ->
{error, bad_repo_name}
end.

%% @private
sign_protobuf(Payload, PrivateKey) ->
Signature = sign(Payload, PrivateKey),
Expand Down
35 changes: 35 additions & 0 deletions src/hex_repo.erl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
get_names/1,
get_versions/1,
get_package/2,
get_policy/2,
get_tarball/3,
get_tarball_to_file/4,
get_docs/3,
Expand Down Expand Up @@ -88,6 +89,40 @@ get_package(Config, Name) when is_binary(Name) and is_map(Config) ->
end,
get_protobuf(Config, <<"packages/", Name/binary>>, Decoder).

%% @doc
%% Gets policy resource from the repository.
%%
%% Requires `repo_organization' to be set in the config; policies are
%% always served from the per-organization namespace
%% (`/repos/<organization>/policies/<name>'). Returns
%% `{error, missing_repo_organization}' when it is not set.
%%
%% Examples:
%%
%% ```
%% > Config = (hex_core:default_config())#{repo_organization => <<"myorg">>},
%% > hex_repo:get_policy(Config, <<"strict-prod">>).
%% {ok, {200, ...,
%% #{repository => <<"myorg">>,
%% name => <<"strict-prod">>,
%% visibility => 'VISIBILITY_PUBLIC'}}}
%% '''
%% @end
get_policy(Config, Name) when is_binary(Name) and is_map(Config) ->
case maps:get(repo_organization, Config, undefined) of
undefined ->
{error, missing_repo_organization};
Org when is_binary(Org) ->
Verify = maps:get(repo_verify_origin, Config, true),
Decoder = fun(Data) ->
case Verify of
true -> hex_registry:decode_policy(Data, Org, Name);
false -> hex_registry:decode_policy(Data, no_verify, no_verify)
end
end,
get_protobuf(Config, <<"policies/", Name/binary>>, Decoder)
end.

%% @doc
%% Gets tarball from the repository.
%%
Expand Down
34 changes: 33 additions & 1 deletion test/hex_registry_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ suite() ->
[{require, {ssl_certs, [test_pub, test_priv, hexpm_pub]}}].

all() ->
[names_test, versions_test, package_test, signed_test].
[names_test, versions_test, package_test, policy_test, signed_test].

names_test(_Config) ->
TestPublicKey = ct:get_config({ssl_certs, test_pub}),
Expand Down Expand Up @@ -126,6 +126,38 @@ package_test(_Config) ->
),
ok.

policy_test(_Config) ->
TestPublicKey = ct:get_config({ssl_certs, test_pub}),
TestPrivateKey = ct:get_config({ssl_certs, test_priv}),
Policy = #{
repository => <<"myorg">>,
name => <<"strict-prod">>,
description => <<"Production policy">>,
visibility => 'VISIBILITY_PUBLIC',
advisory_min_severity => 3,
retirement_reasons => [1, 2],
cooldown => <<"14d">>
},
Payload = hex_registry:build_policy(Policy, TestPrivateKey),
?assertMatch(
{ok, Policy},
hex_registry:unpack_policy(Payload, <<"myorg">>, <<"strict-prod">>, TestPublicKey)
),
?assertMatch(
{error, bad_repo_name},
hex_registry:unpack_policy(Payload, <<"other">>, <<"strict-prod">>, TestPublicKey)
),
?assertMatch(
{error, bad_repo_name},
hex_registry:unpack_policy(Payload, <<"myorg">>, <<"other">>, TestPublicKey)
),
EncodedPayload = hex_registry:encode_policy(Policy),
?assertMatch({ok, Policy}, hex_registry:decode_policy(EncodedPayload, no_verify, no_verify)),
%% unpack while skipping repo/name check; signature still verified
{ok, _} =
hex_registry:unpack_policy(Payload, no_verify, no_verify, TestPublicKey),
ok.

signed_test(_Config) ->
TestPublicKey = ct:get_config({ssl_certs, test_pub}),
TestPrivateKey = ct:get_config({ssl_certs, test_priv}),
Expand Down
22 changes: 22 additions & 0 deletions test/hex_repo_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ all() ->
get_names_test,
get_versions_test,
get_package_test,
get_policy_test,
get_policy_missing_org_test,
get_tarball_test,
get_tarball_to_file_test,
get_docs_test,
Expand Down Expand Up @@ -60,6 +62,26 @@ get_package_test(_Config) ->
{ok, {403, _, _}} = hex_repo:get_package(?CONFIG, <<"nonexisting">>),
ok.

get_policy_test(_Config) ->
Config = maps:put(repo_organization, <<"myorg">>, ?CONFIG),

{ok,
{200, _, #{
repository := <<"myorg">>,
name := <<"strict-prod">>,
visibility := 'VISIBILITY_PUBLIC',
advisory_min_severity := 3,
retirement_reasons := [1, 2],
cooldown := <<"14d">>
}}} = hex_repo:get_policy(Config, <<"strict-prod">>),

{ok, {403, _, _}} = hex_repo:get_policy(Config, <<"nonexisting">>),
ok.

get_policy_missing_org_test(_Config) ->
{error, missing_repo_organization} = hex_repo:get_policy(?CONFIG, <<"strict-prod">>),
ok.

get_tarball_test(_Config) ->
{ok, {200, #{<<"etag">> := ETag}, Tarball}} = hex_repo:get_tarball(
?CONFIG, <<"ecto">>, <<"1.0.0">>
Expand Down
18 changes: 18 additions & 0 deletions test/support/hex_http_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,24 @@ fixture(get, <<?TEST_REPO_URL, "/packages/ecto">>, _, _) ->
},
{ok, {200, Headers, Compressed}};

fixture(get, <<?TEST_REPO_URL, "/repos/myorg/policies/strict-prod">>, _, _) ->
Policy = #{
repository => <<"myorg">>,
name => <<"strict-prod">>,
description => <<"Production policy">>,
visibility => 'VISIBILITY_PUBLIC',
advisory_min_severity => 3,
retirement_reasons => [1, 2],
cooldown => <<"14d">>
},
Payload = hex_registry:encode_policy(Policy),
Signed = hex_registry:sign_protobuf(Payload, ?PRIVATE_KEY),
Compressed = zlib:gzip(Signed),
Headers = #{
<<"etag">> => <<"\"dummy\"">>
},
{ok, {200, Headers, Compressed}};

fixture(get, <<?TEST_REPO_URL, "/tarballs/ecto-1.0.0.tar">>, _, _) ->
Headers = #{
<<"etag">> => <<"\"dummy\"">>
Expand Down
Loading