Skip to content

PUT and GET APIs for projects, credentials and collections, and one error format for every Lightning API - #5239

Draft
stuartc wants to merge 45 commits into
mainfrom
stuart/con-331-con-334-credentials-collections
Draft

stuartc wants to merge 45 commits into
mainfrom
stuart/con-331-con-334-credentials-collections

Conversation

@stuartc

@stuartc stuartc commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Description

This branch does two things. It adds idempotent PUT and GET endpoints for projects, credentials and collections, open to a person's token or the service account. It then moves every JSON API error onto one shape: an RFC 9457 problem document with a fixed code a client can switch on, produced by one fixed order of checks, so one condition always gets one status. It is the Lightning side of jig's apply for CON-329 (projects), CON-331 (credentials) and CON-334 (collections). The API contract is ADR 0024 in the jig repo. It replaces #5238, which this stacked on.

The branch is 20 commits on main at b034e0290e, grouped by how the code fits together rather than by history. Each commit compiles and passes its own tests, so you can review commit by commit.

New endpoints

  • PUT /api/projects/:id creates a project under a client-chosen id or replaces its name, description and members. GET is open to the service account too.
  • PUT and GET /api/credentials/:id. A service account names the owner by email, and a person can only name themselves. Project links are only ever added.
  • PUT and GET /api/projects/:project_id/collections/:id.
  • Service-account scopes projects:write, credentials:write and collections:write. Sending the same body twice changes nothing and writes no audit event.

How authorisation works (see SERVICE_ACCOUNTS.md)

  • Authentication only proves who is calling. Lightning.Scope describes the caller: actor, grants, instance role, MFA status and, once a project is involved, facts about it such as being scheduled for deletion.
  • Policies decide from the scope in one order: grant, then standing, then project state. A missing grant is 403 insufficient_scope, checked before any lookup, so it is 403 even for an id that doesn't exist. A caller with the grant but no standing gets 404, so a caller who can't see something learns nothing about it. Only a caller who would otherwise be allowed hears 409 scheduled_for_deletion.
  • Controllers ask the policy by action name through the AuthorizeAction plug, before any body parse or lookup.
  • Writes record an actor (Lightning.Actor: a user or the service account) in the audit trail.

What a client sees

Every error from /api/*, /collections/*, /adaptors/catalogue, /i/* and the channel proxy's own answers is a problem document: type is always about:blank, with status, title, code, a fixed detail, and an errors list only when the cause has a place in the request. Each errors entry has one location member (pointer, parameter, header or path). The content type is application/problem+json when the client asks for it with Accept, and the same body as application/json otherwise. The OAuth token endpoint, /metrics and LiveView are out of scope.

Breaking changes for existing callers:

  1. Every error body changes shape, and the detail text is no longer translated ("This field can't be blank." becomes "can't be blank"). The "errors": {} member is gone from success bodies.
  2. No standing on a resource is 404, not 401, 403 or 422. A path id that isn't a UUID is 404.
  3. The scope is checked before any lookup, so a missing grant is 403 even for an id that doesn't exist.
  4. 401 means a failed login only, and every 401 carries WWW-Authenticate. An unmet MFA requirement is 403. A malformed Bearer header is 400 invalid_request (was 401). Basic on a bearer route counts as no token.
  5. A service-account token request with no scope is refused with 400 invalid_scope instead of getting every scope.
  6. /api/users needs an operator token, reads fields from the body only, and refuses unknown keys with 422 unknown_key. A taken email is 409 taken with existing.id.
  7. Plan limits are 402 (were 400, 403 or 422), and 429 on /i/*. A catalogue that isn't loaded is 503 not_ready with Retry-After: 5, not a 90 second wait.
  8. A credential deleted while in use is 409 in_use (was 500). Two jobs with one name are 409 taken (was 422). A non-object JSON body is 400 malformed.
  9. PUT /collections with a bad value is 422 (was 200 with "error": "Format error"), so jobs that silently lost writes now fail.
  10. Errors that happen before the router (bad request, timeout, too-long URL or body, wrong content type) on /api and /collections are problem documents instead of HTML.
  11. Updating or re-scheduling a credential that is scheduled for deletion is refused for everyone. Repo-connection tokens get 403 insufficient_scope with a WWW-Authenticate header on the new APIs and their own project's older read routes (was 401). A non-owner reading a credential gets 404, not 403.
  12. List self links carry only the query parameters the server applied.
  13. GET /api/projects/:project_id/workflows/new and .../workflows/:id/edit no longer route. Both answered 500 before.

Commits after #5239's own

The groups, in order: A1-A2 (shared format, renderer, a Credo check that every changeset error has a code), B1 (#5239's four controllers on the format), C1-C4 (scope before lookup, errors before the router, provisioning), D1-D7 (workflows, jobs, runs, work orders, log lines, AI assistant, /collections, webhooks and channel proxy, the review round on D), E1-E6 (raised errors, the 401 edge, /api/users, lookups and pagination moved into contexts, a lost save race answers 409 concurrent_edit, test-file splits), F1-F5 (what core and web each own, error modules merged 15 to 12, status-only codes named as HTTP names them, no Basic challenge on triggers and channels, one concurrent_edit path), T (test reorganisation and the rules file for the API tests) and Z (CHANGELOG.md and SERVICE_ACCOUNTS.md). git log --oneline on the branch lists them with their letters.

Validation steps

  1. mix test test/lightning_web/controllers/api test/lightning/policies test/lightning/scope_test.exs
  2. With SERVICE_ACCOUNT_PUBLIC_KEY set, get a token with scope=projects:write credentials:write collections:write, then PUT a project, a credential and a collection twice with the same body. Expect 201 then 200, and no second audit event.
  3. jig's scripts/e2e.sh against this branch runs the lot end to end. Set LIGHTNING_REPO to this branch until jig's default is re-pinned to its tip.

What the final check on the rebased tip found: mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict --all, mix dialyzer (4 errors, all skipped by the ignore file) and mix sobelow all pass. The full suite is 7194 tests with three failures under load, all passing alone on the same seed. One ProjectsTest export test (channels are included in the export) failed once under load and passed on four reruns and alone. It sorts channels by a timestamp stored to the second, and it predates this branch. Over HTTP against a dev server, a plan-limit refusal gave 402 exceeds_limit on the workflows API and 429 exceeds_limit on a webhook, with the fixed detail and none of the limiter's own text.

Additional notes for the reviewer

Needs a deliberate yes:

  1. With credentials:write a service account can create a credential owned by any user and link it to any project (ADR 0022). The 409 id_taken on an owner mismatch is what stops it rewriting someone else's credential.
  2. A person naming any owner but themselves gets 403, even for an unknown email, so the endpoint can't be used to find accounts.
  3. API DELETE of a scheduled credential still deletes it at once, as today.

Follow-ups are in Linear: CON-357 (remaining policies onto Scope), CON-358 (kit's 409 message), CON-359 (contexts take a Scope, plus a Credo check).

The CHANGELOG entries on this branch are still to be rewritten by Stu before merge.

AI Usage

Please disclose whether you've used AI anywhere in this PR (it's cool, we just want to know!):

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review with Claude Code)
  • I have implemented and tested all related authorization policies. (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog. The entries are still to be rewritten by Stu.
  • I have ticked a box in "AI usage" in this PR

@github-project-automation github-project-automation Bot moved this to New Issues in Core Oct 9, 2026
@stuartc
stuartc added this pull request to stack #5240 October 9, 2026 19:38
@codecov

codecov Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.95336% with 59 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.8%. Comparing base (b034e02) to head (9b6e852).

Files with missing lines Patch % Lines
lib/lightning_web/api/errors.ex 89.2% 10 Missing ⚠️
lib/lightning/auth_providers/auth_config_form.ex 33.3% 4 Missing ⚠️
lib/lightning/workflows/graph_rules.ex 94.4% 4 Missing ⚠️
lib/lightning_web/api/filters.ex 84.0% 4 Missing ⚠️
...tning_web/controllers/api/credential_controller.ex 97.8% 3 Missing ⚠️
...b/lightning_web/controllers/webhooks_controller.ex 85.0% 3 Missing ⚠️
lib/lightning/collections.ex 97.4% 2 Missing ⚠️
lib/lightning/credentials.ex 97.8% 2 Missing ⚠️
lib/lightning/credentials/oauth_validation.ex 93.9% 2 Missing ⚠️
lib/lightning/invocation/query.ex 92.9% 2 Missing ⚠️
... and 20 more
Additional details and impacted files
@@           Coverage Diff           @@
##            main   #5239     +/-   ##
=======================================
+ Coverage   91.5%   91.8%   +0.3%     
=======================================
  Files        466     479     +13     
  Lines      23841   24250    +409     
=======================================
+ Hits       21813   22252    +439     
+ Misses      2028    1998     -30     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@stuartc
stuartc force-pushed the stuart/con-331-con-334-credentials-collections branch 2 times, most recently from a64781c to 3a2c84f Compare October 12, 2026 14:33
@stuartc
stuartc removed this pull request from stack #5240 October 12, 2026 14:35
@stuartc stuartc changed the title PUT and GET APIs for credentials and collections, open to service accounts PUT and GET APIs for projects, credentials and collections, with policies that decide from the caller's scope Oct 12, 2026
@stuartc
stuartc changed the base branch from stuart/con-329-projects to main October 12, 2026 14:35
@stuartc stuartc closed this Oct 13, 2026
@stuartc
stuartc deleted the stuart/con-331-con-334-credentials-collections branch October 13, 2026 09:55
@github-project-automation github-project-automation Bot moved this from New Issues to Done in Core Oct 13, 2026
@stuartc
stuartc restored the stuart/con-331-con-334-credentials-collections branch October 13, 2026 09:55
@stuartc stuartc reopened this Oct 13, 2026
@stuartc
stuartc force-pushed the stuart/con-331-con-334-credentials-collections branch from 7e5c724 to aa30927 Compare October 22, 2026 14:33
@stuartc stuartc changed the title PUT and GET APIs for projects, credentials and collections, with policies that decide from the caller's scope PUT and GET APIs for projects, credentials and collections, and one error format for every Lightning API Oct 22, 2026
@stuartc
stuartc force-pushed the stuart/con-331-con-334-credentials-collections branch from 6335ecf to 260da25 Compare October 23, 2026 13:38
@stuartc
stuartc force-pushed the stuart/con-331-con-334-credentials-collections branch from 260da25 to 9b6e852 Compare October 23, 2026 15:59
stuartc and others added 25 commits October 28, 2026 11:26
A value in the body that must be unique and is already in use is a
taken entry in the 422 like any other body problem, so a client finds
every body problem in one place. 409 stays for the state of the record
the URL names: id_taken, scheduled_for_deletion, concurrent_edit and
the rest.

POST /api/users no longer sends existing with a taken email. A refusal
never names another record, since that would hand the holder's id to
any token with users:write. A client finds the holder with
GET /api/users?email=, which needs users:read.

The API rules say the same, and note that users have their own read
grant rather than reads riding on users:write.
…ate, non-object body is 400, limits come after write-time errors (#5264)

* Treat a provisioning body id that isn't a UUID as a create, and answer a non-object body 400 malformed

An id that isn't a UUID names no project, so the request is a create. A
caller who may not create projects is refused that, and anyone else gets
a 422 with an invalid entry on id. The id is refused rather than cast,
since Ecto.UUID.cast/1 takes any 16-byte string and would create the
project under an id the body doesn't name.

A body that isn't a JSON object is now a 400 malformed.

* Apply provisioning plan limits after the write and the credential check, so a duplicate or scoping violation is reported first

The limiters add what the document asks for to what is stored, so they
are still asked before the write. Their answer is now matched inside the
transaction, after the write and the credential check, so a document is
refused for what is wrong with it before it is refused for a limit.

The refusal's detail is now a fixed "would exceed a limit of your plan"
rather than the limiter's own message.
…with 400, and decide a PUT on a held id by the URL's parent in the API rules
…in the API rules, and carry the location in :invalid_id
…ed id is answered, and that a policy grant may be one of several

The rules follow the two API design documents. A caller the rule refuses
hears 403 whether or not the record exists; only a caller who may read
every record of that kind hears 404 or 400. A nil subject asks the policy
about the instance. Target's lookups take the record's id and a getter,
Decision takes a list of grants, and a credential PUT naming another owner
is 422 not_allowed at #/owner.
…t_found}, project id first, and list records for the projects a caller may see

Runs, WorkOrders, Workflows, Jobs and the new
Collections.get_collection_for_project take the project id first, end in
Repo.fetch_one, and answer a malformed id as not found. The LiveViews,
channels and hooks match the tuple.

Projects.visible_project_ids/1 answers which projects a caller may list:
a repo connection its own, the service account every one, a person (a
superuser included) their memberships.
…ioning in the rule, and ask :create_credential before creating one

Holding any one grant in the list is enough; a refusal names the first.
projects:write or projects:provision covers describing and provisioning an
existing project. :provision_new_project refuses the service account in the
rule rather than only at the grant check. :see_credential with no
credential answers whether the caller may see every credential, which only
the operator with credentials:write may. Scope.fetch/2 answers a malformed
id as such, and Scope.unknown/3 is gone.
…with 400 only to a caller who may make the request

Target.in_project/5 takes the record's id and a getter and checks the id
after permission, before the lookup. Target.in_project_of/5 locates a
top-level record's project and asks project/3 of it. Target.record/4 takes
the permission and, for an id that names nothing, asks the policy with a
nil subject. Target takes a project only as a request parameter or a loaded
record.

Request carries where a malformed id came from, so the 400 names the path
segment, query parameter or body pointer. Filters take the scope and
require project_id from the service account. Codes word not_found,
forbidden and invalid_parameter as the contract states them, and an
Ecto.Query.CastError that reaches the endpoint is a 500.

The shared who-may-read table covers every read route, varying the parent
project and the record id for each kind of caller.
…e service account reaches them under the same rules

Provisioning, credentials, workflows, jobs, work orders, runs and log
lines answered the service account 401. They now take its token and judge
it as every other route does. The CLI deploy test expects the CLI's 403
message for a project the user isn't in.
…03 whether or not it exists, and a PUT naming another owner with 422

Only the service account, which may read every credential, hears 404 or
400 for a missing or malformed id on GET. A PUT on an existing credential
is an update, so id_taken never arises there. Each route has a
who-may-reach-it table.
…, and read POST /api/provision's project id from the body only

A malformed id is 400 to a superuser or the service account and 403 to
anyone else. An id in the query no longer picks the project a document
updates. PUT /api/projects/:id reads its id once. Each route has a
who-may-reach-it table, unmet MFA included.
…s through Target, so a refused caller hears 403 for any id

The nested routes check the path's project first, so a record outside it
is 404 to a permitted caller. The top-level routes locate the record's
project through Target.in_project_of. GET /api/workflows/:id routes by its
path alone. A log lines project_id is checked as the project the lines come
from, and the service account must name one to list runs, work orders or
log lines.
…permitted on the project hearing 409 for an id another project holds

Collections look up by id within the path's project. /collections/:name
still answers 404 for a name no project the token can see has, on every
route.
…ssion and the users routes' refusals with 403

A job the caller can't see lists no sessions, as an unknown one does; a
malformed job_id is 400. A superuser hears 404 for an unknown AI assistant
project_id and 403 for a project they aren't in. The service account short
of a users grant hears 403 insufficient_scope, malformed id included.
…and 400 for a record the request names, and what the service account now reaches

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants