defmodule GitGud.Secrets do
@moduledoc """
Encrypted-at-rest secrets and plaintext variables, scoped per
repository or per organization.
Encryption: AES-256-GCM. The master key is base64-encoded in
`config :git_gud, GitGud.Secrets, master_key: "..."` (32 bytes
decoded). On rotation, set a new `master_key` and keep the previous
one available under `master_keys: %{"old-id" => "<b64>"}` — rows
store `key_id` so a re-wrap job can re-encrypt them lazily.
Resolution for a job: organization secrets first, then repository
secrets overlay them. Variables resolve the same way. Repo always
wins on name conflict — matches GitHub.
"""
import Ecto.Query, warn: false
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories.Repository
alias GitGud.Secrets.OrganizationSecret
alias GitGud.Secrets.OrganizationVariable
alias GitGud.Secrets.RepositorySecret
alias GitGud.Secrets.RepositoryVariable
# ── crypto ───────────────────────────────────────────────────────────
@aad "gitgud-secret-v1"
@doc """
Encrypt `plaintext` with the current master key. Returns
`{:ok, %{ciphertext, iv, tag, key_id}}`.
"""
def encrypt(plaintext) when is_binary(plaintext) do
{key_id, key} = current_master_key()
iv = :crypto.strong_rand_bytes(12)
{ciphertext, tag} =
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, plaintext, @aad, true)
{:ok, %{ciphertext: ciphertext, iv: iv, tag: tag, key_id: key_id}}
end
@doc """
Decrypt a row's ciphertext using the key matching its `key_id`.
"""
def decrypt(%{ciphertext: c, iv: iv, tag: tag, key_id: kid}) do
case lookup_master_key(kid) do
{:ok, key} ->
case :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, c, @aad, tag, false) do
plaintext when is_binary(plaintext) -> {:ok, plaintext}
:error -> {:error, :decrypt_failed}
end
:error ->
{:error, {:no_key, kid}}
end
end
defp current_master_key do
cfg = Application.get_env(:git_gud, __MODULE__, [])
primary_id = Keyword.get(cfg, :primary_key_id, "default")
{primary_id, decode_key!(Keyword.fetch!(cfg, :master_key))}
end
defp lookup_master_key(kid) do
cfg = Application.get_env(:git_gud, __MODULE__, [])
primary_id = Keyword.get(cfg, :primary_key_id, "default")
cond do
kid == primary_id ->
{:ok, decode_key!(Keyword.fetch!(cfg, :master_key))}
true ->
cfg
|> Keyword.get(:master_keys, %{})
|> Map.get(kid)
|> case do
nil -> :error
b64 -> {:ok, decode_key!(b64)}
end
end
end
defp decode_key!(b64) when is_binary(b64) do
key = Base.decode64!(b64)
if byte_size(key) != 32, do: raise("master_key must decode to 32 bytes"), else: key
end
# ── repo secrets ─────────────────────────────────────────────────────
def list_repo_secret_names(%Repository{id: rid}) do
from(s in RepositorySecret,
where: s.repository_id == ^rid,
order_by: [asc: s.name],
select: s.name
)
|> Repo.all()
end
def put_repo_secret(%Repository{id: rid}, name, value) when is_binary(value) do
{:ok, enc} = encrypt(value)
%RepositorySecret{repository_id: rid}
|> RepositorySecret.changeset(Map.put(enc, :name, name))
|> Repo.insert(
on_conflict: {:replace, [:ciphertext, :iv, :tag, :key_id, :updated_at]},
conflict_target: [:repository_id, :name]
)
end
def delete_repo_secret(%Repository{id: rid}, name) do
Repo.delete_all(
from s in RepositorySecret, where: s.repository_id == ^rid and s.name == ^name
)
:ok
end
# ── org secrets ──────────────────────────────────────────────────────
def list_org_secret_names(%Organization{id: oid}) do
from(s in OrganizationSecret,
where: s.organization_id == ^oid,
order_by: [asc: s.name],
select: s.name
)
|> Repo.all()
end
def put_org_secret(%Organization{id: oid}, name, value) when is_binary(value) do
{:ok, enc} = encrypt(value)
%OrganizationSecret{organization_id: oid}
|> OrganizationSecret.changeset(Map.put(enc, :name, name))
|> Repo.insert(
on_conflict: {:replace, [:ciphertext, :iv, :tag, :key_id, :updated_at]},
conflict_target: [:organization_id, :name]
)
end
def delete_org_secret(%Organization{id: oid}, name) do
Repo.delete_all(
from s in OrganizationSecret, where: s.organization_id == ^oid and s.name == ^name
)
:ok
end
# ── variables ────────────────────────────────────────────────────────
def list_repo_variables(%Repository{id: rid}) do
from(v in RepositoryVariable, where: v.repository_id == ^rid, order_by: [asc: v.name])
|> Repo.all()
end
def put_repo_variable(%Repository{id: rid}, name, value) do
%RepositoryVariable{repository_id: rid}
|> RepositoryVariable.changeset(%{name: name, value: value})
|> Repo.insert(
on_conflict: {:replace, [:value, :updated_at]},
conflict_target: [:repository_id, :name]
)
end
def delete_repo_variable(%Repository{id: rid}, name) do
Repo.delete_all(
from v in RepositoryVariable, where: v.repository_id == ^rid and v.name == ^name
)
:ok
end
def list_org_variables(%Organization{id: oid}) do
from(v in OrganizationVariable, where: v.organization_id == ^oid, order_by: [asc: v.name])
|> Repo.all()
end
def put_org_variable(%Organization{id: oid}, name, value) do
%OrganizationVariable{organization_id: oid}
|> OrganizationVariable.changeset(%{name: name, value: value})
|> Repo.insert(
on_conflict: {:replace, [:value, :updated_at]},
conflict_target: [:organization_id, :name]
)
end
def delete_org_variable(%Organization{id: oid}, name) do
Repo.delete_all(
from v in OrganizationVariable, where: v.organization_id == ^oid and v.name == ^name
)
:ok
end
# ── resolution ───────────────────────────────────────────────────────
@doc """
Build the `{secrets, variables}` maps a runner sees for a workflow
job. Org-scoped entries are loaded first, then overlaid by repo-
scoped entries (repo wins).
`secrets` values are decrypted plaintext; treat them as sensitive.
`variables` are returned as-is.
"""
def resolve_for(%Repository{} = repo) do
repo = Repo.preload(repo, :organization)
org_secrets =
case repo.organization do
nil -> %{}
org -> all_org_secrets_decrypted(org)
end
repo_secrets = all_repo_secrets_decrypted(repo)
secrets = Map.merge(org_secrets, repo_secrets)
org_vars =
case repo.organization do
nil -> %{}
org -> list_org_variables(org) |> to_map()
end
repo_vars = list_repo_variables(repo) |> to_map()
vars = Map.merge(org_vars, repo_vars)
{secrets, vars}
end
defp all_repo_secrets_decrypted(%Repository{id: rid}) do
from(s in RepositorySecret, where: s.repository_id == ^rid)
|> Repo.all()
|> Enum.flat_map(&decrypt_pair/1)
|> Map.new()
end
defp all_org_secrets_decrypted(%Organization{id: oid}) do
from(s in OrganizationSecret, where: s.organization_id == ^oid)
|> Repo.all()
|> Enum.flat_map(&decrypt_pair/1)
|> Map.new()
end
defp decrypt_pair(row) do
case decrypt(row) do
{:ok, plaintext} -> [{row.name, plaintext}]
_ -> []
end
end
defp to_map(rows), do: Map.new(rows, &{&1.name, &1.value})
end
7.9 KiB · text
5af55d9