SIGN IN SIGN UP

fix(authz): answer denials honestly and let owners work in their own projects (LE-1905) (#14621)

* fix(authz): answer denials honestly and let owners work in their own projects

Five defects from the LE-1905 RBAC post-V1 report that live in OSS.

Audit rows could not tell a permission check from a performed action
(finding 1). Opening a flow in the editor runs a share:create check, and
the guard wrote that check under the same action name the created-share
row uses, with no marker either way. Guards now tag every row they write
as an authorization decision, and the authz_share routes tag theirs as
the mutation, so "what was evaluated" and "what happened" are separable
in the API, the CSV export, and the UI.

A denial on a resource the caller can already read answered 404
(finding 8). The mask exists so a caller cannot probe UUIDs they have no
access to; it buys nothing once they have opened the resource, and it
costs them a truthful answer -- "verify the flow_id and try again" sends
someone to debug an identifier that is correct. deny_to_404_unless_readable
re-checks read on a denial: readable is a 403 naming the missing
permission, unreadable stays a 404. Applied to project edit and delete,
POST /api/v2/workflows and POST /api/v1/build/{id}/flow, matching what
the flow edit path already did.

Owner override did not cover creating a flow in a project you own
(finding 11). A create has no flow owner yet, so the destination project
is the only ownership the check can consult, and it was not passed. A
user holding a read-only role could not use the default project created
for them. The destination owner is now read from the stored folder row
after canonicalization -- never echoed from the request, which would let
a caller assert ownership of a project they do not own.

Privileged /authz routes validated the request body before authorizing
(finding 7). A caller with no role assignments received the same 422
field names and enum literals a superuser would, enough to map the
request contract of a route they cannot invoke. The superuser gate is now
a route dependency, which FastAPI solves before body validation. The
in-body call stays as the gate for anything reaching the endpoint without
dependency resolution.

Truncated project names in the sidebar were unreachable (finding 12) --
no tooltip, and the sidebar cannot be widened. With one default project
per user the list fills with rows that differ only in the truncated part.

Two enforcement tests asserted the behavior the report calls defective
and are updated: a viewer creating a flow in their own project now
succeeds (and is still refused in someone else's), and a read-only share
holder denied execute now gets the execute-permission 403 rather than a
404 for a flow they can see.

* fix(plugins): report a plugin config failure as what it is, not a route conflict

Every ValueError from a plugin's register() was logged as "rejected (route
conflict)". An Enterprise plugin whose SIEM configuration was invalid raised
one during import, so operators saw a conflict warning and went looking for a
duplicate path -- while the real symptom was that none of that plugin's routes
had registered at all. Only a genuine conflict now takes that branch, via a
dedicated RouteConflictError, and the other branch says the routes are missing.

* fix(authz): preserve service failures and make plugin registration atomic

Review follow-ups on the LE-1905 changes.

deny_to_404_unless_readable caught every HTTPException from its read
check, including a 5xx from the authorization plugin, and answered 404.
That reported an outage as a missing resource and sent the caller to
check an identifier that was fine — the same class of misdirection the
helper exists to remove. Only a 403 from the read check means "cannot
see it"; any other status is surfaced unchanged.

Plugin registration was not atomic: a plugin that mounted several routes
before one conflicted stayed half-live, reported as rejected while part
of its surface answered requests. For an authorization plugin that is
worse than registering nothing, because the app looks functional. Both
failure branches now roll the plugin's routes back and say how many were
withdrawn, which is also what makes the new log message true — it
claimed none of the routes were available while some were. Rollback is
kept out of the wrapper handed to plugins, since a plugin able to roll
back to an arbitrary snapshot could delete Langflow's own routes. Only
routes are undone; a plugin that installed a lifespan hook or dependency
override before failing keeps it, and the docstring says so.

Tests: a read check that raises 503 preserves the status; partial mounts
are rolled back and their reservations released so a later plugin can
claim the abandoned path; a neighbouring plugin's failure leaves a
successful one intact; the ordering matrix now covers role PATCH, team
PATCH and team-member POST, with a guard that fails if a new
superuser-gated body route is added without being listed; and the
sidebar project-name tooltip has frontend cover (two of its three cases
fail without the title attribute).

* fix(authz): let execute-only callers run from the Playground, and stop auditing capability probes

Round 2 of LE-1905. The canvas always posts its own nodes and edges, so the
owner-only graph-override gate denied every non-owner Playground run and
reframed it to 404 FLOW_NOT_FOUND. The built-in Viewer and Editor both hold
flow:execute and could run the same flow through the API, which is why this
read as "works via API, 404 via UI".

Overriding the stored graph is an edit expressed at run time, so it is now
gated on flow:write: a caller who holds write can already persist that graph
and run it. An execute-only caller has the override dropped and runs the
stored definition -- what flow:execute means -- instead of being told the flow
does not exist. The security property is unchanged: caller-supplied graph data
still never runs for someone who cannot edit the flow.

Also adds capability_probe(), which evaluates a permission for a UI capability
answer without writing an authorization-decision row. The share-capability
probe fires once per rendered resource, so it wrote a share:create row for
every project in the sidebar, with no resource and no share behind it.

* feat(authz): classify every audit row and let a reader filter on the class

Alice's round-2 finding 1 notes that audit:read and rbac_policy:startup_reconcile
rows carry no event class, so a reader filtering on it silently loses them. Adds
the two missing classes -- access for something a user did that changed nothing,
system for something the server did with no user behind it -- so every row can be
classified.

GET /api/v1/authz/audit gains event and exclude_event so "show me what people
did" is a server-side filter with an honest total and honest pagination rather
than a client-side sieve over a page. An untagged row can never satisfy an
include and is never dropped by an exclude, so rows written before
classification existed stay visible.

* test(authz): follow the override gate into the contract it is recorded in

Round 2 moved the run-time graph override from ownership to flow:write, but
left two artifacts describing the behavior it replaced.

The v1 build route test still pinned the 404 that the fix removed, so it failed
as DID NOT RAISE. It now asserts the property that actually matters -- the
caller-supplied graph never reaches the build worker -- and a companion pins
the other half: a non-owner holding write keeps the override.

The execution-principal matrix named a v2 test that the same commit renamed,
which is exactly the drift the checker exists to catch. Both build entrypoints
also still declared tweaks as owner_only; they are owner_or_writer now, so the
vocabulary gains that value. v1_run keeps owner_only -- its tweaks gate is
untouched.

* fix(sharing): move the project Share control into the three-dot menu

Knowledge Bases, Files and Deployments all render Share as an item in their
row menu. The projects sidebar was the one surface that rendered it as a
second control beside the menu trigger, and unlike that trigger it had no
hover gating -- so it was the only permanently visible control in the list,
on every row. The sidebar is the narrowest surface in the app, which is where
the extra control costs the most.

The menu was a Select driven by a value, which is a form control standing in
for a command menu: it cannot host the shared Share item, and it announces
itself as a combobox to a screen reader. It is a DropdownMenu now, matching
the other three surfaces, so Share is simply another item. The rename,
download and delete entries keep their test ids and route through the same
handler as before.

Reserving room for two controls widened the row to pr-16 when the icon was
added. One control needs pr-8, and the 2rem goes back to the project name --
which is truncated in the sidebar and cannot be widened.
E
Eric Hare committed
e12b340d86638a60bb1ae24101e00dc29f618d1f
Parent: d4ab9b8
Committed by GitHub <noreply@github.com> on 8/18/2026, 10:04:41 PM