Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9e70e80
Drop the /commit command and turn off Claude attribution in settings
stuartc Oct 9, 2026
c9e67cf
Let service accounts with projects:write through GET /api/projects/:i…
stuartc Oct 9, 2026
d12ebc7
Give service accounts project standing in Scope and ProjectUsers
stuartc Oct 9, 2026
5b01698
Make the service-account permissions test classify every ProjectUsers…
stuartc Oct 9, 2026
ef4c6ab
Refuse a failing service-account token with invalid_token on the pers…
stuartc Oct 9, 2026
341cd39
Declare the projects:write scope on the :projects_api pipeline and re…
stuartc Oct 9, 2026
d2b650e
Write out the service account's allowed project actions and inline it…
stuartc Oct 9, 2026
d124eb3
Add Lightning.Actor, the person or service account a context function…
stuartc Oct 9, 2026
5eb8fcb
Record who created a project and added its first members, and take no…
stuartc Oct 9, 2026
305b0c4
Require an actor on update_project and audit name and description cha…
stuartc Oct 9, 2026
c482307
Let a superuser or a service account create a project, through Provis…
stuartc Oct 9, 2026
10bfa48
Show a project's members on GET /api/projects/:id
stuartc Oct 9, 2026
1317772
Return a taken project id as an error on id instead of raising
stuartc Oct 9, 2026
badaf18
Create or replace a project with PUT /api/projects/:id
stuartc Oct 9, 2026
49d5bd9
Refuse a token request that names no scope with invalid_scope
stuartc Oct 9, 2026
2db452b
Answer GET /api/projects/:id with 409 for a scheduled project and 403…
stuartc Oct 9, 2026
9bd9292
Refuse a PUT /api/projects/:id body key it does not take with 422 und…
stuartc Oct 9, 2026
e93b38f
Keep members' emails out of PUT /api/projects/:id 422 messages
stuartc Oct 9, 2026
f57b591
Restore the benchmark scripts' formatting, keeping create_project's a…
stuartc Oct 9, 2026
0738e03
Resolve the project's Scope once in ProjectController instead of read…
stuartc Oct 9, 2026
c4a30e0
Resolve members by email in one query through Projects.members_by_ema…
stuartc Oct 9, 2026
84aa785
Flatten a member's own errors into plain messages under members on PU…
stuartc Oct 9, 2026
4486f97
Roll create_project back with an error when its audit events cannot b…
stuartc Oct 9, 2026
983d044
Refuse a notify of null on PUT /api/projects/:id with 422 under notify
stuartc Oct 9, 2026
4da5775
Audit name and description changes made through update_project_with_u…
stuartc Oct 9, 2026
78b9930
Build Scope's and Provisioning's actor types on Lightning.Actor
stuartc Oct 9, 2026
d72762b
Resolve members through Accounts.list_users_by_emails and take their …
stuartc Oct 9, 2026
1047dd5
Use Scope.actor in Provisioning's authorize spec instead of a copy
stuartc Oct 9, 2026
038fc01
Call create_sandbox directly instead of through create_empty_sandbox
stuartc Oct 9, 2026
373d807
Share the service-account and scheduled-project helpers and leave the…
stuartc Oct 9, 2026
9d2dd33
Write the owner row last so an ownership handover never trips the one…
stuartc Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 0 additions & 34 deletions .claude/commands/commit.md

This file was deleted.

6 changes: 6 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"attribution": {
"commit": "",
"pr": ""
}
}
2 changes: 1 addition & 1 deletion .claude/skills/implement-plan/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ Plans from `/create-plan` carry three things a fresh session should honour:

When the code disagrees with the plan, stop and ask before adapting.

When every phase passes: `/code-review`, fix what holds, then `/commit`.
When every phase passes: `/code-review`, fix what holds, then commit.
Report any manual criteria still outstanding.
8 changes: 8 additions & 0 deletions lib/lightning/actor.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
defmodule Lightning.Actor do
@moduledoc """
Whoever a context function records as having done something: a person or a
service account.
"""

@type t :: Lightning.Accounts.User.t() | Lightning.ServiceAccount.t()
end
39 changes: 35 additions & 4 deletions lib/lightning/policies/project_users.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@ defmodule Lightning.Policies.ProjectUsers do
Adding an action means adding an atom to `@admin_actions` or
`@editor_actions`. It inherits the guard; there is nothing to remember.

A service account holds no role. Its authority is the scope its token
carries, which `LightningWeb.Plugs.PersonOrServiceAccountAuth` checks at the
route; here it may do only what `@service_account_actions` lists. That list
is separate and written out, so a new admin or editor action is refused to
the service account until it is added there.

We deny by default: an action in none of the lists is refused.
"""
@behaviour Bodyguard.Policy

alias Lightning.Accounts.User
alias Lightning.Projects.ProjectUser
alias Lightning.Projects.Scope
alias Lightning.ServiceAccount

@admin_actions [
:write_webhook_auth_method,
Expand Down Expand Up @@ -52,6 +59,27 @@ defmodule Lightning.Policies.ProjectUsers do

@other_actions [:access_project, :delete_project, :publish_template]

@service_account_actions [
:write_webhook_auth_method,
:write_github_connection,
:edit_project,
:edit_data_retention,
:add_project_user,
:remove_project_user,
:edit_run_settings,
:create_workflow,
:edit_workflow,
:delete_workflow,
:run_workflow,
:create_project_credential,
:initiate_github_sync,
:create_channel,
:delete_channel,
:update_channel,
:access_project,
:delete_project
]

@type actions ::
:access_project
| :delete_project
Expand Down Expand Up @@ -84,10 +112,10 @@ defmodule Lightning.Policies.ProjectUsers do
do: @admin_actions ++ @editor_actions ++ @self_actions ++ @other_actions

@doc """
Whether `user` may perform `action`, where the subject is anything that
Whether `actor` may perform `action`, where the subject is anything that
identifies a project — see `t:Lightning.Projects.Scope.subject/0`.
"""
@spec authorize(actions(), User.t(), Scope.subject()) :: boolean()
@spec authorize(actions(), Scope.actor(), Scope.subject()) :: boolean()

# These act on a specific membership row, not on project standing. The alert
# handlers in `ProjectLive.Settings` pass a client-supplied "project_user_id",
Expand All @@ -109,8 +137,8 @@ defmodule Lightning.Policies.ProjectUsers do
)
end

def authorize(action, %User{} = user, subject) do
case Scope.fetch(user, subject) do
def authorize(action, actor, subject) do
case Scope.fetch(actor, subject) do
{:ok, scope} ->
permitted?(action, scope)

Expand All @@ -133,6 +161,9 @@ defmodule Lightning.Policies.ProjectUsers do
# later — without anyone having to remember @admin_actions/@editor_actions.
def permitted?(_action, %Scope{mfa_satisfied?: false}), do: false

def permitted?(action, %Scope{actor: %ServiceAccount{}}),
do: action in @service_account_actions

def permitted?(:access_project, %Scope{} = scope), do: has_standing?(scope)

def permitted?(:delete_project, %Scope{role: role}), do: role == :owner
Expand Down
22 changes: 15 additions & 7 deletions lib/lightning/policies/provisioning.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,36 @@ defmodule Lightning.Policies.Provisioning do
so a push could re-enable the triggers that shutting the project down had
disabled.

Only a superuser can provision a project that does not exist yet. Owners and
admins can update an existing one.
Only a superuser or a service account can create a project, through
`:create_project`; provisioning a project that does not exist yet asks the
same. Owners and admins can update an existing one.
"""
@behaviour Bodyguard.Policy

alias Lightning.Accounts.User
alias Lightning.Policies.Permissions
alias Lightning.Projects.Project
alias Lightning.Projects.Scope
alias Lightning.ServiceAccount
alias Lightning.VersionControl.ProjectRepoConnection

@type actor :: User.t() | ProjectRepoConnection.t()
@type actions :: :provision_project | :describe_project
@type actions :: :create_project | :provision_project | :describe_project

@spec authorize(actions(), actor(), Project.t()) ::
@spec authorize(actions(), Scope.actor(), Project.t() | nil) ::
boolean() | {:error, :forbidden}

def authorize(:create_project, %User{role: :superuser}, _), do: true

# The route already held the token to its projects scope.
def authorize(:create_project, %ServiceAccount{}, _), do: true

def authorize(:create_project, _, _), do: {:error, :forbidden}

# STAYS FIRST. A project that does not exist yet cannot be scheduled for
# deletion, and has no members to consult. Reordering this below the clauses
# that resolve a Scope breaks project creation through the API.
def authorize(:provision_project, %User{role: role}, %Project{id: nil}) do
role in [:superuser] or {:error, :forbidden}
def authorize(:provision_project, %User{} = user, %Project{id: nil}) do
authorize(:create_project, user, nil)
end

def authorize(:provision_project, %User{} = user, %Project{} = project) do
Expand Down
120 changes: 97 additions & 23 deletions lib/lightning/projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -547,22 +547,87 @@ defmodule Lightning.Projects do
|> Repo.all()
end

@member_roles ProjectUser.RolesEnum.__valid_values__()
|> Enum.filter(&is_binary/1)

@doc """
Resolves members given as `%{"email" => email, "role" => role}` to the
`%{user_id: id, role: role}` rows a project's `project_users` take.

Emails match regardless of case. The messages never contain an email, so
they can be shown to whoever submitted the list.
"""
@spec members_by_email([term()]) ::
{:ok,
[%{user_id: Ecto.UUID.t(), role: :owner | :admin | :editor | :viewer}]}
| {:error, [String.t()]}
def members_by_email(members) do
if Enum.all?(members, &member_shape?/1) do
user_ids =
members
|> Enum.map(& &1["email"])
|> Lightning.Accounts.list_users_by_emails()
|> Map.new(&{String.downcase(&1.email), &1.id})

members
|> Enum.map(fn %{"email" => email, "role" => role} ->
%{
user_id: Map.get(user_ids, String.downcase(email)),
role: String.to_existing_atom(role)
}
end)
|> check_members()
else
{:error,
[
"each member needs an email and a role of #{Enum.join(@member_roles, ", ")}"
]}
end
end

defp member_shape?(%{"email" => email, "role" => role}),
do: is_binary(email) and role in @member_roles

defp member_shape?(_member), do: false

defp check_members(members) do
{found, missing} = members |> Enum.map(& &1.user_id) |> Enum.split_with(& &1)

messages =
for {true, message} <- [
{missing != [], "names an email no user holds"},
{found != Enum.uniq(found), "names one user more than once"}
],
do: message

if messages == [], do: {:ok, members}, else: {:error, messages}
end

@doc """
Creates a project.
Creates a project, recording `actor` as having created it and added each of
its initial members.

## Options

* `:notify` - email each initial member that they were added (default `true`)

## Examples

iex> create_project(%{field: value})
iex> create_project(%{field: value}, actor)
{:ok, %Project{}}

iex> create_project(%{field: bad_value})
iex> create_project(%{field: bad_value}, actor)
{:error, %Ecto.Changeset{}}

"""
def create_project(attrs \\ %{}, schedule_email? \\ true) do
@spec create_project(map(), Lightning.Actor.t(), keyword()) ::
{:ok, Project.t()} | {:error, Ecto.Changeset.t()}
def create_project(attrs, actor, opts \\ []) do
Repo.transact(fn ->
with {:ok, project} <- ProjectHook.handle_create_project(attrs) do
if schedule_email? do
with {:ok, project} <- ProjectHook.handle_create_project(attrs),
project = Repo.preload(project, :project_users),
{:ok, _events} <- record_creation(project, actor) do
if Keyword.get(opts, :notify, true) do
schedule_project_addition_emails(%Project{project_users: []}, project)
end

Expand All @@ -574,27 +639,40 @@ defmodule Lightning.Projects do
end)
end

defp record_creation(project, actor) do
Multi.new()
|> Audit.derive_creation_events(project, actor)
|> Repo.transaction()
|> case do
{:ok, events} -> {:ok, events}
{:error, _operation, changeset, _changes_so_far} -> {:error, changeset}
end
end

@doc """
Updates a project.

## Examples

iex> update_project(project, %{field: new_value})
iex> update_project(project, %{field: new_value}, actor)
{:ok, %Project{}}

iex> update_project(project, %{field: bad_value})
iex> update_project(project, %{field: bad_value}, actor)
{:error, %Ecto.Changeset{}}

"""
def update_project(%Project{} = project, attrs, user \\ nil) do
@spec update_project(Project.t(), map(), Lightning.Actor.t()) ::
{:ok, Project.t()}
| {:error, Ecto.Changeset.t() | :not_related_to_project}
def update_project(%Project{} = project, attrs, actor) do
changeset =
project
|> Project.changeset(attrs)
|> ProjectHook.handle_project_validation()

Multi.new()
|> Multi.update(:project, changeset)
|> maybe_audit_changes(changeset, user)
|> Audit.derive_events(changeset, actor)
|> Repo.transaction()
|> case do
{:ok, %{project: updated_project}} ->
Expand All @@ -617,13 +695,6 @@ defmodule Lightning.Projects do
end
end

defp maybe_audit_changes(multi, _changeset, nil), do: multi

defp maybe_audit_changes(multi, changeset, user) do
multi
|> Audit.derive_events(changeset, user)
end

@spec update_project_with_users(Project.t(), map(), User.t(), boolean()) ::
{:ok, Project.t()} | {:error, Ecto.Changeset.t()}
def update_project_with_users(
Expand All @@ -638,6 +709,7 @@ defmodule Lightning.Projects do

project
|> membership_multi(changeset, actor)
|> Audit.derive_events(changeset, actor)
|> Repo.transaction()
|> case do
{:ok, %{project: updated_project, membership_changes: changes}} ->
Expand Down Expand Up @@ -2257,10 +2329,10 @@ defmodule Lightning.Projects do
end

@doc """
Creates a sandbox under the given `parent` by delegating to `create_project/2`.
Creates a sandbox under the given `parent` by delegating to `create_project/3`.

This is a convenience wrapper that sets `:parent_id` and preserves the
existing behavior around collaborator emails (off by default unless `schedule_email?` is `true`).
This is a convenience wrapper that sets `:parent_id` and emails no
collaborators.

## Notes

Expand All @@ -2274,10 +2346,12 @@ defmodule Lightning.Projects do
* `{:ok, %Project{}}` on success
* `{:error, %Ecto.Changeset{}}` on validation/unique errors
"""
@spec create_sandbox(Project.t(), map(), boolean()) ::
@spec create_sandbox(Project.t(), map(), Lightning.Actor.t()) ::
{:ok, Project.t()} | {:error, Ecto.Changeset.t()}
def create_sandbox(%Project{id: parent_id}, attrs, schedule_email? \\ false) do
attrs |> Map.put(:parent_id, parent_id) |> create_project(schedule_email?)
def create_sandbox(%Project{id: parent_id}, attrs, actor) do
attrs
|> Map.put(:parent_id, parent_id)
|> create_project(actor, notify: false)
end

@doc """
Expand Down
Loading