Repository navigation
Conversation
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
stuartc
force-pushed
the
stuart/con-331-con-334-credentials-collections
branch
2 times, most recently
from
October 12, 2026 14:33
a64781c to
3a2c84f
Compare
4 of 7 tasks
stuartc
force-pushed
the
stuart/con-331-con-334-credentials-collections
branch
from
October 22, 2026 14:33
7e5c724 to
aa30927
Compare
stuartc
force-pushed
the
stuart/con-331-con-334-credentials-collections
branch
from
October 23, 2026 13:38
6335ecf to
260da25
Compare
…he caller's grants
…nd refuse deleting a credential in use
…or it in the LiveViews
…before a controller
…y, with one fallback for refusals
…with concurrent_edit
…limit /api/users to operators
…he comment and API test rules
stuartc
force-pushed
the
stuart/con-331-con-334-credentials-collections
branch
from
October 23, 2026 15:59
260da25 to
9b6e852
Compare
… references to docs outside the repo
…e problem document check
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.
…es, and cite AIP-211
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
This branch does two things. It adds idempotent
PUTandGETendpoints 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 fixedcodea 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/:idcreates a project under a client-chosen id or replaces its name, description and members.GETis open to the service account too.PUTandGET /api/credentials/:id. A service account names the owner by email, and a person can only name themselves. Project links are only ever added.PUTandGET /api/projects/:project_id/collections/:id.projects:write,credentials:writeandcollections:write. Sending the same body twice changes nothing and writes no audit event.How authorisation works (see
SERVICE_ACCOUNTS.md)Lightning.Scopedescribes the caller: actor, grants, instance role, MFA status and, once a project is involved, facts about it such as being scheduled for deletion.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 409scheduled_for_deletion.AuthorizeActionplug, before any body parse or lookup.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:typeis alwaysabout:blank, withstatus,title,code, a fixeddetail, and anerrorslist only when the cause has a place in the request. Eacherrorsentry has one location member (pointer,parameter,headerorpath). The content type isapplication/problem+jsonwhen the client asks for it withAccept, and the same body asapplication/jsonotherwise. The OAuth token endpoint,/metricsand LiveView are out of scope.Breaking changes for existing callers:
"errors": {}member is gone from success bodies.WWW-Authenticate. An unmet MFA requirement is 403. A malformedBearerheader is 400invalid_request(was 401).Basicon a bearer route counts as no token.scopeis refused with 400invalid_scopeinstead of getting every scope./api/usersneeds an operator token, reads fields from the body only, and refuses unknown keys with 422unknown_key. A taken email is 409takenwithexisting.id./i/*. A catalogue that isn't loaded is 503not_readywithRetry-After: 5, not a 90 second wait.in_use(was 500). Two jobs with one name are 409taken(was 422). A non-object JSON body is 400malformed.PUT /collectionswith a bad value is 422 (was 200 with"error": "Format error"), so jobs that silently lost writes now fail./apiand/collectionsare problem documents instead of HTML.insufficient_scopewith aWWW-Authenticateheader on the new APIs and their own project's older read routes (was 401). A non-owner reading a credential gets 404, not 403.selflinks carry only the query parameters the server applied.GET /api/projects/:project_id/workflows/newand.../workflows/:id/editno 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 409concurrent_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, oneconcurrent_editpath), T (test reorganisation and the rules file for the API tests) and Z (CHANGELOG.mdandSERVICE_ACCOUNTS.md).git log --onelineon the branch lists them with their letters.Validation steps
mix test test/lightning_web/controllers/api test/lightning/policies test/lightning/scope_test.exsSERVICE_ACCOUNT_PUBLIC_KEYset, get a token withscope=projects:write credentials:write collections:write, thenPUTa project, a credential and a collection twice with the same body. Expect 201 then 200, and no second audit event.scripts/e2e.shagainst this branch runs the lot end to end. SetLIGHTNING_REPOto 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) andmix sobelowall pass. The full suite is 7194 tests with three failures under load, all passing alone on the same seed. OneProjectsTestexport 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 402exceeds_limiton the workflows API and 429exceeds_limiton a webhook, with the fixed detail and none of the limiter's own text.Additional notes for the reviewer
Needs a deliberate yes:
credentials:writea service account can create a credential owned by any user and link it to any project (ADR 0022). The 409id_takenon an owner mismatch is what stops it rewriting someone else's credential.DELETEof 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!):
You can read more details in our Responsible AI Policy
Pre-submission checklist
/reviewwith Claude Code):owner,:admin,:editor,:viewer)