10.7 KiB · text 5af55d9
defmodule GitGud.Organizations do
@moduledoc """
Organizations + teams + per-repo team permissions.
Authorization model:
* Repo owner (user or org admin) → admin on the repo.
* Org admin → admin on every repo owned by the org.
* Team member → the team's repo permission, capped by their team role.
Use `permission/2` to ask "what can this user do to this repo?".
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Organizations.Membership
alias GitGud.Organizations.Organization
alias GitGud.Organizations.Team
alias GitGud.Organizations.TeamMembership
alias GitGud.Organizations.TeamRepository
alias GitGud.Repo
alias GitGud.Repositories.Repository
# ── orgs ─────────────────────────────────────────────────────────────
def list_organizations_for(%User{id: uid}) do
from(o in Organization,
join: m in Membership,
on: m.organization_id == o.id and m.user_id == ^uid,
order_by: [asc: o.handle]
)
|> Repo.all()
end
def get_organization!(id), do: Repo.get!(Organization, id)
def get_organization_by_handle!(handle),
do: Repo.get_by!(Organization, handle: String.downcase(handle))
def get_organization_by_handle(handle) when is_binary(handle),
do: Repo.get_by(Organization, handle: String.downcase(handle))
@doc "Apply a profile-fields update on an organization."
def update_organization_profile(%Organization{} = org, attrs) do
org |> Organization.profile_changeset(attrs) |> Repo.update()
end
@doc """
Update the org's visibility. Cascading: when tightening (public →
internal/private, or internal → private), every repo whose
visibility is now more open than the org's is bumped to match.
Transactional.
"""
def update_organization_visibility(%Organization{} = org, new_visibility) do
new_rank = Organization.visibility_rank(new_visibility)
Repo.transaction(fn ->
case org |> Organization.visibility_changeset(%{visibility: new_visibility}) |> Repo.update() do
{:ok, updated} ->
# Cascade: every repo with a lower visibility rank than the
# org bubbles up to match.
floor = floor_visibility(new_rank)
if floor do
ranks =
for v <- Organization.valid_visibilities(),
Organization.visibility_rank(v) < new_rank,
do: v
from(r in GitGud.Repositories.Repository,
where: r.organization_id == ^updated.id and r.visibility in ^ranks
)
|> Repo.update_all(set: [visibility: floor, updated_at: DateTime.utc_now(:second)])
end
updated
{:error, cs} ->
Repo.rollback(cs)
end
end)
end
defp floor_visibility(0), do: nil
defp floor_visibility(1), do: "internal"
defp floor_visibility(2), do: "private"
defp floor_visibility(_), do: "private"
@doc """
Create an organization and make `creator` its first admin. Atomic via
a transaction.
"""
def create_organization(%User{id: uid}, attrs) do
result =
Repo.transaction(fn ->
case %Organization{} |> Organization.changeset(attrs) |> Repo.insert() do
{:ok, org} ->
%Membership{}
|> Membership.changeset(%{
organization_id: org.id,
user_id: uid,
role: "admin"
})
|> Repo.insert!()
org
{:error, cs} ->
Repo.rollback(cs)
end
end)
case result do
{:ok, org} ->
# Best-effort profile README repo — same shape as on user
# registration. Logged + ignored on failure.
case GitGud.Repositories.ensure_profile_repo(org) do
{:ok, _} ->
:ok
{:error, reason} ->
require Logger
Logger.warning("could not create profile repo for org #{org.handle}: #{inspect(reason)}")
end
{:ok, org}
other ->
other
end
end
def add_member(%Organization{} = org, %User{} = user, role \\ "member") do
case %Membership{}
|> Membership.changeset(%{organization_id: org.id, user_id: user.id, role: role})
|> Repo.insert(
on_conflict: {:replace, [:role]},
conflict_target: [:organization_id, :user_id]
) do
{:ok, membership} = ok ->
_ = GitGud.Federation.Publishing.publish_org_member_added(org, user, role)
_ = membership
ok
err ->
err
end
end
def remove_member(%Organization{} = org, %User{} = user) do
Repo.delete_all(
from m in Membership, where: m.organization_id == ^org.id and m.user_id == ^user.id
)
# User is no longer affiliated with this org — drop their personal
# pins of its repos so the profile doesn't keep advertising work
# they can't access. Org-level pins are unaffected (they belong to
# the org, not the removed user).
_ = GitGud.Profiles.unpin_org_repos(user, org)
:ok
end
def org_admin?(%Organization{id: oid}, %User{id: uid}) do
Repo.exists?(
from m in Membership,
where: m.organization_id == ^oid and m.user_id == ^uid and m.role == "admin"
)
end
def org_admin?(_, _), do: false
@doc "True if the user has any membership in the org."
def member?(%Organization{id: oid}, %User{id: uid}) do
Repo.exists?(
from m in Membership,
where: m.organization_id == ^oid and m.user_id == ^uid
)
end
def member?(_, _), do: false
@doc """
List memberships for an org with `:user` preloaded. Ordered by role
(admins first) then by user handle / email.
"""
def list_members(%Organization{id: oid}) do
from(m in Membership,
where: m.organization_id == ^oid,
join: u in assoc(m, :user),
order_by: [
fragment("CASE WHEN ? = 'admin' THEN 0 ELSE 1 END", m.role),
asc: u.handle,
asc: u.email
],
preload: [user: u]
)
|> Repo.all()
end
defp admin_count(%Organization{id: oid}) do
Repo.one(
from m in Membership,
where: m.organization_id == ^oid and m.role == "admin",
select: count(m.id)
)
end
@doc """
Change a member's role. Refuses to demote the last admin — the org
must always have at least one admin. Returns
`{:ok, membership}` or `{:error, :last_admin | :not_a_member}`.
"""
def set_member_role(%Organization{} = org, %User{} = user, new_role)
when new_role in ["admin", "member"] do
case Repo.get_by(Membership, organization_id: org.id, user_id: user.id) do
nil ->
{:error, :not_a_member}
%Membership{role: ^new_role} = m ->
{:ok, m}
%Membership{role: "admin"} = m ->
if admin_count(org) <= 1 do
{:error, :last_admin}
else
m |> Ecto.Changeset.change(role: new_role) |> Repo.update()
end
%Membership{} = m ->
m |> Ecto.Changeset.change(role: new_role) |> Repo.update()
end
end
@doc """
Remove a member. Refuses to remove the last admin so the org always
has at least one admin. Returns `:ok`, `{:error, :last_admin}`, or
`{:error, :not_a_member}`.
"""
def remove_member_guarded(%Organization{} = org, %User{} = user) do
case Repo.get_by(Membership, organization_id: org.id, user_id: user.id) do
nil ->
{:error, :not_a_member}
%Membership{role: "admin"} ->
if admin_count(org) <= 1 do
{:error, :last_admin}
else
:ok = remove_member(org, user)
:ok
end
_ ->
:ok = remove_member(org, user)
:ok
end
end
# ── teams ────────────────────────────────────────────────────────────
def list_teams(%Organization{id: oid}) do
Team
|> where([t], t.organization_id == ^oid)
|> order_by([t], asc: t.name)
|> Repo.all()
end
def get_team!(id), do: Repo.get!(Team, id)
def create_team(%Organization{id: oid}, attrs) do
%Team{organization_id: oid}
|> Team.changeset(attrs)
|> Repo.insert()
end
def add_team_member(%Team{id: tid}, %User{id: uid}, role \\ "member") do
%TeamMembership{}
|> TeamMembership.changeset(%{team_id: tid, user_id: uid, role: role})
|> Repo.insert(
on_conflict: {:replace, [:role]},
conflict_target: [:team_id, :user_id]
)
end
def grant_team_repo(%Team{id: tid}, %Repository{id: rid}, permission) do
%TeamRepository{}
|> TeamRepository.changeset(%{
team_id: tid,
repository_id: rid,
permission: permission
})
|> Repo.insert(
on_conflict: {:replace, [:permission]},
conflict_target: [:team_id, :repository_id]
)
end
# ── permissions ──────────────────────────────────────────────────────
@doc """
Resolve `user`'s effective permission on `repo`. Returns one of
`"admin" | "maintain" | "write" | "triage" | "read" | nil`.
"""
def permission(nil, %Repository{visibility: vis}) when vis in ["public", "internal"], do: "read"
def permission(nil, _), do: nil
def permission(%User{id: uid}, %Repository{owner_id: uid}), do: "admin"
def permission(%User{id: uid} = user, %Repository{organization_id: oid} = repo)
when not is_nil(oid) do
if Repo.exists?(
from m in Membership,
where: m.organization_id == ^oid and m.user_id == ^uid and m.role == "admin"
) do
"admin"
else
team_permission(user, repo) || visibility_fallback(repo)
end
end
def permission(%User{} = user, %Repository{} = repo) do
team_permission(user, repo) || visibility_fallback(repo)
end
defp team_permission(%User{id: uid}, %Repository{id: rid}) do
perms =
Repo.all(
from tr in TeamRepository,
join: tm in TeamMembership,
on: tm.team_id == tr.team_id and tm.user_id == ^uid,
where: tr.repository_id == ^rid,
select: tr.permission
)
case perms do
[] ->
nil
list ->
list
|> Enum.max_by(&TeamRepository.rank/1)
end
end
defp visibility_fallback(%Repository{visibility: vis}) when vis in ["public", "internal"],
do: "read"
defp visibility_fallback(_), do: nil
@doc "True if `user` can perform `action` on `repo`."
def can?(user, %Repository{} = repo, action)
when action in ~w(read triage write maintain admin)a do
have = permission(user, repo)
have && TeamRepository.rank(have) >= TeamRepository.rank(Atom.to_string(action))
end
def can?(_, _, _), do: false
end