* feat: add OSS authorization foundations for enterprise RBAC
Introduce pluggable AuthorizationService, authz plugin tables, and flow
API enforcement hooks. Disabled by default (LANGFLOW_AUTHZ_ENABLED=false);
enterprise Casbin plugin can override via lfx.services entry point.
* fix(authz): allow non-superusers when OSS authorization service is active
OSS LangflowAuthorizationService is a pass-through until enterprise Casbin
replaces it; the previous fail-closed superuser-only check caused 403 on flow APIs.
* fix(authz): harden OSS authorization foundations (bug + tests + docs) (#13216)
* fix: harden OSS authorization foundations (PR (a) — bug + lint + tests)
Hardening pass for #13153 to clear pre-merge checks and raise unit-test
coverage on the new authorization surface before broader feature work.
- Fix read_public_flow: query the flow directly and return when
access_type == PUBLIC; remove user-impersonation via
get_user_by_flow_id_or_endpoint_name and the read_flow delegation
(public means public — no authz call).
- Fix _token_alias_candidates ordering so the _access_token branch is
reachable (previously shadowed by the _token check).
- Register BaseAuthorizationService in import_all_services_into_a_dict so
AuthorizationServiceFactory can resolve its create() return annotation
at startup (mirrors the BaseAuthService pattern).
- Rename test_non_wxo_access_token_does_not_create_bearer_alias to
test_generic_access_token_exposes_bearer_alias; body asserts the alias
IS created.
- Uppercase env-var literals in test_minimal_services to clear
Ruff SIM112/S105; per-line noqa only for the lowercased global header
name x-langflow-global-var-access-token.
- Add unit coverage for batch_enforce (enabled/disabled/empty),
get_allowed_actions, ensure_permission, ensure_flow_permission,
AuthorizationServiceFactory.create, and the LFX no-op default
AuthorizationService.
- Persist+read tests for the seven previously-untested authz models
(AuthzRoleAssignment, AuthzTeam, AuthzTeamMember, AuthzShare,
AuthzEditLock, AuthzAuditLog).
- One-line docstrings on undocumented public symbols across the new
authorization modules and authz database models.
* fix(authz): align authz_role.permissions model nullability with migration
Migration f7a8b9c0d1e2 declares ``permissions`` as ``nullable=False`` but
the model used the default ``Column(sa.JSON)`` (nullable=True), which made
``test_no_phantom_migrations`` detect a phantom modify_nullable diff after
upgrading to head. Explicitly set ``nullable=False`` so the model matches
the migration.
* feat(authz): finish Phase 1 OSS — FlowAction enum, audit, owner override, project domain (#13220)
PR (b) of the OSS authz rollout. Lands every piece the enterprise Casbin
plugin needs to enforce RBAC on flows end-to-end:
- New FlowAction(str, Enum) — canonical action vocabulary (read/write/
create/delete/execute/deploy). Re-used by enterprise PolicySync when
compiling roles + shares into casbin_rule.
- ensure_flow_permission now accepts FlowAction|str, computes domain
from folder_id ('project:{folder_id}' or '*'), and applies an owner
override: when current_user.id == flow.user_id, the call short-circuits
before contacting the plugin (so solo installs keep working with
LANGFLOW_AUTHZ_ENABLED=true). Owner-override is audited too, with
result='owner_override'.
- Guards wired on all 9 flow CRUD routes (was 4): create, read, patch,
delete, put-upsert (write|create branches), batch-create, upload,
bulk-delete, bulk-download. /public_flow/{id} stays unguarded by
design (public means public), and GET /flows/ list-filtering is
deferred to a follow-up PR.
- audit_decision() schedules an AuthzAuditLog row via asyncio.create_task
on every allow/deny/owner_override decision. Failures inside the task
are logged and swallowed so audit never blocks a request. Gated on a
new AUTHZ_AUDIT_ENABLED setting (defaults True; ignored when AUTHZ is
off).
- filter_visible_resources() helper — generic batch_enforce-based filter
for list endpoints. No-op when AUTHZ disabled. Plumbing for PR (c);
GET /flows/ rewrite is intentionally NOT in this PR.
- BaseAuthorizationService gains invalidate_user / invalidate_role /
invalidate_all hooks (no-op default bodies). Enterprise plugin
overrides these for L1/L2 cache invalidation when roles or shares
change.
OSS LangflowAuthorizationService remains a pass-through; enforcement is
entirely the enterprise plugin's job. Tests use a stub fail-closed
service via monkeypatch to verify guards forward the right tuple, and
source-level checks on flows.py ensure no route silently regresses to
a bare-string action or loses its guard.
47 backend + 12 lfx new/extended tests. Migration regression suite
still green.
* feat(authz): add deployment API guards aligned with Phase 1 flow authz
Wire ensure_deployment_permission on deployment CRUD and run routes using
DeploymentAction, project/workspace domain resolution, owner override, and
audit logging consistent with Eric's flow authorization helpers.
* feat(authz): add `langflow authz dry-run` CLI for policy simulation (#13224)
Adds a self-contained subcommand that simulates every flow-CRUD guard
site under a configurable stub policy and prints what *would* happen —
the Casbin tuple (user, domain, obj, act), the audit row that would be
written, and the resulting HTTP outcome. No network, no DB writes.
Useful for:
- validating an upcoming Casbin policy before shipping it
- showing operators what the audit log will look like
- smoke-testing the guard wiring after a refactor
Built-in policies:
- allow-all (matches the OSS default)
- deny-non-owner (owners + superusers allowed)
- deny-writes (read/execute allowed; write/create/delete denied)
- owner-only (only the flow owner; no superuser bypass)
Usage:
langflow authz dry-run
langflow authz dry-run --policy deny-writes
langflow authz dry-run --policy deny-non-owner --json --output report.json
Implementation: monkey-patches the three helpers in
langflow.services.authorization.utils (get_settings_service,
get_authorization_service, audit_decision) for the duration of the run,
walks _FLOW_GUARDS x three actors (owner / non-owner / superuser), and
renders a Rich table or JSON document with per-row decision + summary
footer.
Tests: 18 cases covering each stub policy, the runner output shape, JSON
serialisation, and CLI integration via typer.testing.CliRunner.
* fix(authz): keep user-derived is_superuser authoritative in ensure_permission
Caller-supplied context was merged after `_auth_context(user)`, which let a
caller forge `context={"is_superuser": True}` and bypass intent of the guard.
Flip the merge order so user-derived auth fields always win, and add a
regression test that asserts a malicious caller cannot override the flag.
* fix(authz): per-flow CREATE checks in batch and upload paths
The batch (POST /flows/batch/) and upload (POST /flows/upload/) handlers were
calling ensure_flow_permission once at the route boundary without per-flow
scope. Because the FlowCreate payload carries caller-supplied workspace_id and
folder_id, a single coarse check let payloads target workspaces/folders the
caller has no create permission on — invisible under OSS pass-through but a
real bypass once an enterprise plugin enforces workspace-scoped roles.
Both handlers now iterate flow_list.flows and call ensure_flow_permission with
the flow's own workspace_id and the effective folder_id (the query parameter
wins on upload, matching _upsert_flow_list's actual write behavior).
Adds two AST-level regression tests that assert each route calls
ensure_flow_permission inside a `for ... in flow_list.flows` loop with the
flow's destination kwargs.
* fix(authz): authorize destination scope when PATCH/PUT moves a flow
update_flow (PATCH) and upsert_flow's update branch (PUT) only checked WRITE
permission against the existing flow's workspace_id/folder_id. Because
_patch_flow and _update_existing_flow apply payload workspace_id/folder_id via
model_dump(exclude_unset=True, exclude_none=True), a caller authorized to
write a flow in scope A could move it into scope B without ever being checked
against B — invisible under OSS pass-through but a real bypass once an
enterprise plugin enforces workspace/share-scoped roles.
Both handlers now compute the effective destination (payload wins when
non-None, else existing) and, when it differs from the existing scope, run a
second ensure_flow_permission(WRITE, ...) call against the destination. No
extra DB hits — the audit row already captures move attempts.
Adds two AST-level regression tests asserting each route makes the
destination check using target_workspace_id/target_folder_id kwargs.
* fix(authz): partial unique indexes on authz_role_assignment for NULL domain_id
The original UNIQUE(user_id, role_id, domain_type, domain_id) constraint on
authz_role_assignment allowed duplicate global role assignments because SQL
treats NULL domain_id values as never-equal. Two ("global", NULL) rows for the
same (user, role) pair both passed.
Replaces the broken constraint with two partial unique indexes:
- uq_authz_role_assignment_scoped: unique on (user_id, role_id, domain_type,
domain_id) WHERE domain_id IS NOT NULL — covers workspace/org assignments.
- uq_authz_role_assignment_global: unique on (user_id, role_id, domain_type)
WHERE domain_type='global' AND domain_id IS NULL — covers global assignments
without relying on NULL equality.
Postgres and SQLite both support partial unique indexes via WHERE clauses.
New Alembic migration (Phase: EXPAND) drops the broken constraint and creates
the two indexes, using batch_alter_table so SQLite recreates the table cleanly.
Three new DB-level regression tests in test_authz_models.py:
- test_authz_role_assignment_blocks_duplicate_global
- test_authz_role_assignment_blocks_duplicate_scoped
- test_authz_role_assignment_allows_distinct_workspaces
* test(lfx): remove redundant @pytest.mark.asyncio decorators
src/lfx/pyproject.toml has asyncio_mode = "auto", so pytest-asyncio
auto-detects async test functions. The decorators on the seven async tests in
test_default_authorization_service.py are no-ops. Removed for clarity; tests
continue to pass via auto-detect (mode=Mode.AUTO confirmed in pytest output).
import pytest stays for the @pytest.fixture decorator on `service`.
* test(authz): complete DeploymentAction enum coverage in test_actions
test_deployment_action_values_match_casbin_strings only asserted READ, CREATE,
EXECUTE — WRITE and DELETE were missing despite being members of the enum.
Mirrored the FlowAction test structure: full value coverage plus a new
test_deployment_action_is_iterable_and_complete that enumerates members.
DeploymentAction has no DEPLOY (deploy is a flow-level action that mints a
deployment), so the expected set is five values.
* fix(authz): partial unique indexes on authz_share for NULL target_id
The original UNIQUE(resource_type, resource_id, scope, target_id) constraint
on authz_share did not prevent duplicate PRIVATE/PUBLIC shares because
target_id is NULL for those scopes and SQL treats NULL values as never-equal.
Replaces the constraint with two partial unique indexes:
- uq_authz_share_targeted: unique on (resource_type, resource_id, scope,
target_id) WHERE target_id IS NOT NULL — covers TEAM/USER scopes.
- uq_authz_share_untargeted: unique on (resource_type, resource_id, scope)
WHERE target_id IS NULL — covers PRIVATE/PUBLIC scopes without relying on
NULL equality.
Chose partial indexes over CodeRabbit's sentinel-UUID suggestion to keep
target_id genuinely nullable (sentinel values leak into every read path that
filters by target_id, and any future FK on target_id would break).
Consistent with the d9e8f7a6b5c4 fix for authz_role_assignment.
New Alembic migration (Phase: EXPAND, validator: ✅) drops the broken
constraint via batch_alter_table and creates both partial indexes.
Three new DB-level regression tests in test_authz_models.py:
- test_authz_share_blocks_duplicate_targeted (USER scope dup → IntegrityError)
- test_authz_share_blocks_duplicate_untargeted (PUBLIC dup → IntegrityError)
- test_authz_share_allows_distinct_targets (sanity: different users OK)
* fix(authz): prefer project over workspace in _resolve_flow_domain
Casbin g2 inheritance is directional: a child resource inherits from its
parent, not the other way round. Declaring `g2, project:xyz, workspace:abc`
lets a workspace-scoped grant flow down to checks made against project:xyz,
but a project-scoped grant does NOT flow up to checks made against
workspace:abc.
The original precedence picked workspace whenever it was set, which made
project-scoped grants invisible to the enforcer the moment a workspace was
also known. Flip the precedence so the more specific domain (project) wins —
project-scoped grants now match directly, and workspace-scoped grants still
match via g2 inheritance. Both ids stay in the enforce context for ABAC.
- _resolve_flow_domain returns project first, then workspace, then "*".
- Docstrings on both _resolve_flow_domain and its callers updated.
- test_ensure_flow_permission_workspace_beats_project renamed to
test_ensure_flow_permission_project_beats_workspace with inverted assertion
and an explanation of g2 directionality.
- test_resolve_flow_domain_precedence updated accordingly.
Deployment helper uses the same function but only one deployment test passes
both ids; deployment behavior is unchanged in tests that pass project_id only.
* feat(authz): Phase 2 — list filtering, execute guards, project routes (#13255)
* feat(authz): Phase 2 — list filtering, execute guards, project routes
Extends the Phase 1 authorization foundations (#13153) with the route coverage
the IBM RBAC design note flags as "Not hooked yet" under §4.1 and the Phase 2
deliverables under §5.3 (workspace/project domain, list/filter APIs).
List filtering
- GET /flows/ now calls filter_visible_resources(FlowAction.READ) on the loaded
list. OSS pass-through returns the input unchanged; the enterprise plugin
uses batch_enforce to honor role + share grants.
- GET /projects/ applies the same filter against ProjectAction.READ.
Execute guards (FlowAction.EXECUTE)
- POST /build/{flow_id}/flow (chat.py): authorizes after loading the flow row.
- POST /run/{flow_id_or_name} (endpoints.py, api-key auth).
- POST /run/session/{flow_id_or_name} (endpoints.py, session auth).
- POST /webhook/{flow_id_or_name} (endpoints.py, webhook auth).
Project (folder) authorization
- New ProjectAction enum (READ/WRITE/CREATE/DELETE).
- New ensure_project_permission helper with workspace domain + owner override,
mirroring the flow/deployment helpers.
- Guards wired into create_project, read_project, update_project,
delete_project, download_file, upload_file.
Tests
- 7 new ensure_project_permission tests in test_utils.py (enum coercion,
workspace domain, owner override, wildcard create object).
- 4 new test_execute_surfaces_guard_with_flow_execute parametrized cases.
- 6 new test_project_routes_guarded parametrized cases.
- test_read_flows_list_unchanged_in_this_pr renamed to
test_read_flows_list_uses_filter_visible_resources; it now asserts the
filter is called exactly once.
Docs
- Added an "Authorization (RBAC)" section to AGENTS.md describing the plugin
model, route helpers, Casbin request shape, and the dry-run CLI.
Out of scope for Phase 2 (per design note §5.3):
- Knowledge bases and global variables (Phase 4: deployments, components,
secrets, audit UI).
- Deployment list filtering (paginated + shaped items; needs SQL-level
prefiltering once authz_share lands in Phase 3).
- authz_share CRUD APIs and UI (Phase 3 — required for full RBAC).
* docs(authz): correct domain precedence note in AGENTS.md
Follows the upstream _resolve_flow_domain fix on the parent branch — project
wins over workspace so project-scoped grants match directly and workspace
grants still flow down via Casbin g2.
* fix(authz): align dry-run CLI with project-over-workspace domain precedence
The _resolve_flow_domain flip (project wins over workspace, ac3e8bcf0f) made
test_domain_is_workspace_prefixed unsatisfiable — every dry-run scenario passes
both workspace_id and folder_id, so every audited row now resolves to
project:{folder_id}.
- Renamed the test to test_domain_is_project_prefixed and updated the assertion.
- Updated the matching fallback literal in authz_dry_run.py so it reflects
what the resolver would actually emit (the fallback fires only when no audit
row is written, which is rare since AUTHZ_ENABLED is forced True in dry-run).
* fix(authz): block-merge fixes — note_translations leak, schema hardening
Eight fixes from the post-merge review of #13153:
C1 security — GET /flows/{flow_id}/note_translations leaked note text across
users. The handler authenticated the caller but never scoped the fetch or
called ensure_flow_permission, so any authenticated user could read note
content from any flow by guessing the UUID. Switch to _read_flow (owner-scoped)
and add ensure_flow_permission(READ); return {} on miss so missing-flow vs
not-owned vs not-authorized are indistinguishable.
C2 database — Casbin's loader filters by ptype on every load_policy() and
AddPolicy(). Without an index, every enterprise policy refresh became a full
table scan on casbin_rule. Add ix_casbin_rule_ptype.
C3 database — Every authz timestamp column was created as sa.DateTime() with
no timezone. Inconsistent with deployment/api_key/variable models, and on
Postgres it strips tzinfo on write so audit-log ordering can drift across DST.
Switch to DateTime(timezone=True) on every authz timestamp; migration uses
postgresql_using="column AT TIME ZONE 'UTC'" to convert existing rows safely.
H1 security — uq_authz_role_assignment_global filtered on
(domain_type='global' AND domain_id IS NULL). A row with domain_type='org'
AND domain_id IS NULL was covered by neither partial index and duplicates
slipped through. Widen the filter to domain_id IS NULL (drop the domain_type
check); rename the index to uq_authz_role_assignment_unscoped to reflect that
it covers every NULL-domain_id case.
H2 database — authz_share.created_by was ondelete=CASCADE. Deleting a user
silently revoked every share they created. Mirror the SET NULL pattern from
authz_audit_log.user_id and make the column nullable.
H3 database — "list all shares for resource X" runs WHERE resource_type=? AND
resource_id=? — bitmap-AND of two single-column indexes works but a composite
index is tighter and matches the precedent set by ix_authz_audit_log_resource.
Add ix_authz_share_resource (resource_type, resource_id).
H4 python — noqa: TC002 suppressions on factory.py runtime imports were
hiding a real ruff finding: with `from __future__ import annotations`, those
imports are typecheck-only. Move them into the TYPE_CHECKING block.
H5 python — _authz_settings had no return type annotation. Mypy inferred Any
which propagated through is_enabled() and every downstream consumer. Add the
explicit AuthSettings return type.
Plus a TODO comment on AuthzAuditLog noting the retention/partitioning gap
flagged by the DB reviewer (M7 in the synthesis).
New regression tests:
- test_authz_role_assignment_blocks_duplicate_non_global_with_null_domain_id
- test_get_note_translations_is_owner_scoped_and_guarded
112 authz tests pass; migration validator output Valid: ✅; alembic upgrade
head runs clean against a fresh SQLite DB.
* refactor(authz): collapse duplicated ensure_*_permission helpers
The three public guards (flow/deployment/project) shared ~130 LOC of
copy-paste: object key assembly, action coercion, owner-override check, audit
write, and delegation to ensure_permission. Two changes from the Python
reviewer (M1, M2 in the synthesis):
- Extract a private ``_ensure_resource_permission`` core that owns the
identical structural pattern. The three public helpers become thin wrappers
that build the resource-specific extra_context (with the right key names —
``flow_user_id`` vs ``deployment_user_id`` vs ``project_user_id``) and
delegate. Adding a fourth resource type is now a five-line addition instead
of a 45-line copy.
- Rename ``_resolve_flow_domain`` to ``_resolve_casbin_domain`` because it
serves flows AND deployments (callers pass ``folder_id`` or ``project_id``
through the same helper). Parameter renamed from ``folder_id`` to
``scope_id`` to reflect the polymorphic role. A module-level alias
``_resolve_flow_domain = _resolve_casbin_domain`` preserves the old name
for downstream importers; the test asserts both names resolve to the same
function.
Drive-by: extracted ``_OWNER_CONTEXT_KEYS`` constant so the audit-detail
extraction picks up ``project_user_id`` too (previously only
``flow_user_id`` and ``deployment_user_id`` were forwarded to audit rows).
Skipped the L6 finding (collapse pass-through ``enforce``/``batch_enforce``
into ``BaseAuthorizationService``). Keeping those abstract on the base is a
deliberate safety guard — an enterprise plugin that forgets to override
``enforce`` should fail loudly at instantiation, not silently inherit
allow-all in production. The duplication between LangflowAuthorizationService
and the lfx default is the price.
112 authz tests pass; ruff clean.
* fix(authz): remove redundant owner-only check + per-domain list filter + share CHECK constraints
Three fixes from the post-merge static review:
1. ``check_flow_user_permission`` rejected any flow where
``flow.user_id != api_key_user.id`` — fine for OSS pass-through but a real
regression under enterprise enforcement, because an execute-grant on a
shared flow would still be blocked here even after the route's upstream
``ensure_flow_permission(FlowAction.EXECUTE, ...)`` passed. Remove the
helper entirely; route-level authorization is the only gate now. Added the
missing ``ensure_flow_permission(EXECUTE)`` call to ``experimental_run_flow``
so the contract holds: every ``_run_flow_internal`` caller authorizes
upstream. New ``test_check_flow_user_permission_is_gone`` regression
prevents a re-introduction.
2. ``filter_visible_resources`` sent a single ``domain`` for the entire batch,
so candidates living in different workspaces/projects all evaluated against
the same Casbin tuple — project-scoped grants became invisible. Added an
optional ``domain_extractor`` parameter that groups candidates by their
resolved domain and calls ``batch_enforce`` once per group. Output order is
preserved. ``GET /flows/`` and ``GET /projects/`` now pass extractors that
call ``_resolve_casbin_domain(item.workspace_id, item.folder_id)``. New
``test_filter_visible_resources_groups_by_extracted_domain`` covers the
per-domain dispatch.
3. ``authz_share.scope`` and ``permission_level`` were free-text columns —
typos like ``scope='PRIVATE'`` would silently bypass the partial unique
indexes (which match the lowercase form), and rows could be inserted with
``scope='public'`` + a ``target_id`` (mismatched targeted/untargeted
semantics). Added three CHECK constraints:
- ``ck_authz_share_scope_enum``
- ``ck_authz_share_permission_enum``
- ``ck_authz_share_scope_target_consistency`` (TEAM/USER → target required;
PRIVATE/PUBLIC → target forbidden)
Three new DB-level regression tests.
Phase 3 prerequisite documented (not fixed):
- ``get_flow_for_api_key_user`` and other owner-scoped lookup helpers still
filter by ``user_id``, so enterprise execute-grants on flows the caller
doesn't own would 404 at the lookup before the upstream
``ensure_flow_permission`` can permit them. The proper fix is share-aware
fetch helpers that land alongside ``authz_share`` CRUD in Phase 3.
Out-of-scope (skipped with reason):
- ``FlowAction.DEPLOY`` enum value remains unwired. Design note §5.1 plans it
for WxO publish on flow-version attach; wiring it on every deployment
create/update is Phase 4 work.
118 authz tests pass; ruff clean; migration validator output Valid: ✅;
alembic upgrade head runs clean against a fresh SQLite DB.
* refactor(authz): collapse six pending migrations into one consolidated file
None of the authz migrations have shipped yet — this branch is the first PR
introducing them. The chain had grown to six files as findings landed:
mb01b2c3d4e5
└─ f7a8b9c0d1e2 (initial tables)
└─ c8e5f4b2a9d7 (workspace_id columns)
└─ d9e8f7a6b5c4 (partial unique indexes on role_assignment)
└─ e4f5a6b7c8d9 (partial unique indexes on share)
└─ f0a1b2c3d4e5 (tz timestamps, ptype index, FK, etc.)
└─ b2c3d4e5f6a1 (CHECK constraints on share)
Replace all six with a single 7c8d9e0f1a2b migration that emits the final
schema directly. New tip: mb01b2c3d4e5 → 7c8d9e0f1a2b.
Drive-by model fixes uncovered by test_model_migration_consistency:
- _tz_column helper now accepts index=True so the audit log timestamp
column carries its index (lost in the earlier helper extraction).
- Aligned unique-index vs unique-constraint emission for the three tables
whose models use Field(index=True, unique=True) — emit a single unique
index, not a separate UniqueConstraint, so autogen sees no drift.
- Renamed CHECK constraint base names from ck_authz_share_* to * — the
alembic naming convention prepends ck_<table>_ automatically, so the
previous names were getting doubled. Final names are now correct
(ck_authz_share_scope_enum, etc.).
149 authz + alembic tests pass; ruff clean; migration validator output
Valid: ✅; alembic upgrade head runs clean against a fresh SQLite DB.
* fix: merge
* [autofix.ci] apply automated fixes
* Update Structured Data Analysis Agent.json
* fix(authz): narrow Phase 1 claims + filter paginated /flows/ branch
Two findings from the latest static review:
P1 — Owner-scoped fetch shadows enterprise grants
Route guards (ensure_flow_permission, ensure_deployment_permission,
ensure_project_permission) sit on top of fetch helpers that still scope
queries by current_user.id. So even with an enterprise plugin registered, a
share grant on a non-owned flow / folder / deployment still 404s at the
fetch layer before the guard can authorize. Cross-user reachability is
genuinely Phase 3 work (needs share-aware fetch helpers that load by id
first and convert plugin denies to 404 for UUID-privacy).
Rather than half-fix it, narrow the Phase 1 claims and document the gap
where future readers will see it:
* Module-level docstring on services/authorization/utils.py spelling out
the Phase 1 contract and why cross-user enforcement is not yet reachable.
* Inline "Phase 3 prerequisite" markers at the five lookup sites the
review flagged: flows.py:read_flow, chat.py:build_flow, projects.py:read_project,
endpoints.py:get_flow_for_api_key_user (already had one), and
mappers/deployments/helpers.py:get_deployment_row_or_404.
* AGENTS.md authz section now explicitly calls out the owner-scoped-fetch
limitation.
* PR description updated to scope the deliverables to Phases 1+2 and call
out Phase 3 prerequisites explicitly.
P2 — Paginated /flows/ branch skipped filter_visible_resources
read_flows had two return paths: get_all=True ran filter_visible_resources;
get_all=False (the paginated branch) returned apaginate result raw. So one
public mode of the same endpoint silently sidestepped authz filtering.
The fix wires filter_visible_resources into the paginated branch using the
same domain_extractor. OSS pass-through is a no-op so behavior under the
default flag is unchanged. page.total may overcount denied items under
enterprise enforcement — accurate paginated counts require SQL-level
prefiltering (Phase 3, alongside authz_share).
test_read_flows_list_uses_filter_visible_resources tightened to assert TWO
filter_visible_resources calls (one per branch) so the next regression
where someone forgets one path is caught immediately.
149 authz + alembic tests pass; ruff clean.
* test(authz): add user_id + workspace_id to _fake_deployment_row fixture
ensure_deployment_permission, wired into every guarded deployment handler,
reads deployment_row.user_id (for owner override) and
deployment_row.workspace_id (for domain resolution). The unit-test fixture
``_fake_deployment_row`` predates the authz hooks and didn't populate either
field, causing AttributeError on any test path that flows through a guarded
handler — surfaced by TestUpdateDeploymentRollback.test_rollback_called_on_commit_failure.
Add both fields with sensible defaults (random UUID for user_id matching the
production model, None for workspace_id which resolves to the wildcard domain).
Override-friendly so individual tests can pin specific values.
* fix(authz): tighten flow upload, project download, deployment list guards
Three guard placements were misshapen — coarse where they should be specific,
specific where they should be coarse, or missing context the resolver needs.
P1-1 — flows.py upload_file: remove the pre-parse coarse CREATE check
The handler runs ``ensure_flow_permission(CREATE, folder_id=folder_id)``
before the file is parsed, then runs a per-flow CREATE check after parsing
with the actual ``workspace_id``/``folder_id`` each uploaded flow targets.
The pre-parse check evaluates against ``domain="*", obj="flow:*"`` (or
``project:{folder_id}`` if the optional query param is set), which can
reject a caller who has create permission only inside the project/workspace
the uploaded flows actually target. The per-flow check is the right
enforcement point; drop the pre-parse check entirely.
P1-2 — projects.py download_file: pass owner + workspace into the guard
``ensure_project_permission(ProjectAction.READ, project_id=project_id)``
without ``project_user_id`` or ``workspace_id`` skips the owner-override
short-circuit AND resolves to the wildcard domain. An owner-only role in
the enterprise plugin would then deny the project's own owner; a
workspace-scoped role would never match. Fetch the project row first,
then pass the resolved owner and workspace.
P2-1 — deployments.py list_deployments: filter rows per item
The list endpoint had a single coarse ``deployment:*`` READ check and no
per-row filtering. That forced operators to grant global read to anyone
who should see any deployments — even those scoped to a single project.
Apply ``filter_visible_resources`` on ``rows_with_counts`` before
shaping, with a per-row ``domain_extractor`` so each deployment is
evaluated at ``project:{project_id}``/``workspace:{workspace_id}``. OSS
pass-through returns the input unchanged. ``total`` still reflects the
raw DB count — accurate paginated totals need SQL-level prefilter
(Phase 3, alongside ``authz_share``).
P2-2 (open question) — services/utils.py registration: docstring only
Confirms the OSS default registers with ``override=True`` (same as
``auth_service``) and that enterprise plugins are expected to override via
``lfx.toml`` rather than entry points. Plain entry-point registration uses
``override=False`` in the LFX manager and would lose to the OSS default —
not a bug but worth being explicit so enterprise integrators don't pick
the wrong mechanism.
83 authz + deployment-handler unit tests pass; ruff clean.
* fix(authz): guard deprecated build routes, owner-override list filter, persist workspace_id
Three findings from the latest review.
P1-1 — Deprecated build routes bypassed authorization
POST /build/{flow_id}/vertices (chat.py retrieve_vertices_order) and
POST /build/{flow_id}/vertices/{vertex_id} (chat.py build_vertex) are
hidden from the OpenAPI schema (deprecated=True, include_in_schema=False)
but still routed. Both eventually call build_graph_from_db, which does a
raw session.get(Flow, flow_id) in flow_utils.py with no owner filter, so
any authenticated user could build any other user's flow by guessing a
UUID. Apply the same owner-or-public fetch + ensure_flow_permission(EXECUTE)
the supported build_flow uses. retrieve_vertices_order now binds
current_user explicitly (was previously a dependencies-only declaration).
P1-2 — List filtering hid resources from their owners
filter_visible_resources called batch_enforce with only user context and
no per-candidate owner context. Direct guards in _ensure_resource_permission
short-circuit when owner_id == user.id and force-allow without calling
enforce. The list path did not — so a deny-all enterprise policy would
hide a user's own rows from the listing while the same user could read
each row directly. Made list and direct read disagree.
Added optional owner_extractor parameter. Items whose owner UUID equals
user.id are force-included before the enforcer is consulted; the
enforcer only sees the items that need a real decision. All three list
callers (flows GET /, projects GET /, deployments GET /) pass
``owner_extractor=lambda item: item.user_id`` (or the tuple-aware
equivalent for deployments).
New regression test asserts owner-override path bypasses a deny-all stub.
P2 — workspace_id update silently dropped
FlowUpdate accepts workspace_id and the PATCH/PUT handlers re-authorize
WRITE at the destination scope when the payload changes it. But
_UPDATABLE_FLOW_FIELDS in flows_helpers.py omitted workspace_id, so the
update passed authz, returned 200, and the row stayed in its old
workspace. Add workspace_id to the allowlist so the move actually
persists.
86 authz tests pass; ruff clean.
* test(authz): align deprecated build-route tests with 404 contract + cross-user behavioral coverage
P1 — existing tests asserted 500 for unknown flow ids on the deprecated
/build/{flow_id}/vertices and /build/{flow_id}/vertices/{vertex_id} routes.
The new ownership check returns 404 (matches /build/{flow_id}/flow and
preserves UUID-privacy). Update both assertions and document why.
P2 — the existing AST-level guard test only proves the route SOURCE contains
an ensure_flow_permission call; it does not exercise behavior. Add two
integration tests that:
1. Create a flow owned by the conftest's active_user.
2. Log in as a second, distinct user.
3. Hit /build/{flow_id}/vertices and /build/{flow_id}/vertices/{vertex_id}
with the second user's bearer token.
4. Assert 404 — the same opaque response another user's nonexistent flow
would produce.
The second-user fixture lives inline in this test file since the project's
conftest only ships single-user infrastructure (active_user /
active_super_user).
* feat: Phase 3 and 4 implementations for RBAC
* fixes from round of review
* Round 2 review fixes
* fix: Next round of review fixes
* fix: More hardening of KBs in RBAC
* fix: Third round of review feedback
* fix: Fourth round of review updates
* Update deployments.py
* Few more deployment tightens
* Update deployments.py
* Update test_deployment_route_handlers.py
* Update test_s3_endpoints.py
* [autofix.ci] apply automated fixes
* fix: Route guards and unit tests
* fix: filter paginated branch of read_project
* Update auth.py
* [autofix.ci] apply automated fixes
* [autofix.ci] apply automated fixes
* [autofix.ci] apply automated fixes (attempt 2/3)
* docs(authz): shorten docstrings and drop enterprise/Casbin comments
Use one-line module and helper docstrings across authorization code and
route guards; keep table/type identifiers unchanged.
* refactor(db): consolidate authz migrations into a single revision
Merge system-role seeding into 7c8d9e0f1a2b and drop 8d3a1f9c2e0b.
* fix: audit log retention policy and cleanup
* refactor(authz): split utils.py and address PR #13153 review
Address review feedback on the OSS authorization foundations PR:
- B1/I1: Split services/authorization/utils.py (694 lines, mixed
responsibilities) into focused modules:
- audit.py — batched audit pipeline (audit_decision, writer loop)
- guards.py — ensure_*_permission family (collapsed via _RESOURCE_SPECS
registry; nine 25-line clones become 7 thin wrappers)
- listing.py — filter_visible_resources
utils.py remains as a back-compat re-export shim so existing call sites
(api/v1/flows.py, deployments.py, etc.) keep working untouched.
- B2: Split tests/unit/services/authorization/test_utils.py (1004 lines)
into test_audit.py, test_guards.py, test_filter_visible.py,
test_domain_resolution.py. Shared stubs + monkeypatch helpers extracted
into _common.py; pytest fixtures into conftest.py.
- I2: ensure_permission default 403 detail is now "Permission denied" —
callers can opt into a richer message via the new `detail=` kwarg. The
previous default echoed flow:<uuid> in the response, leaking existence
to non-owners on any route that forgot to wrap in deny_to_404.
- I4: Document the OSS floor contract on _ensure_can_administer_share —
the early-return is dead in OSS (supports_cross_user_fetch() is False)
and the explicit 403 is the actual floor; the enterprise plugin gates
via the downstream ensure_share_permission call.
- R2: Audit drop logging is now time-based (first drop + at most every
10s during persistent saturation) instead of every 1000th drop. Low
drop rates no longer go minutes without a log line.
- R5: Seed migration's non-Postgres/SQLite fallback wraps the INSERT in
a SAVEPOINT and swallows IntegrityError. Two concurrent migration
runners can no longer race past the SELECT-then-INSERT check.
- R6: IntegrityError tests in test_authz_models.py now verify the
*specific* constraint fired — column names for unique partial indexes
(SQLite doesn't surface the index name), constraint name substring
for CHECK constraints. A generic NOT NULL regression would no longer
pass these tests.
All 180 authz tests pass; ruff clean.
* fix: One more review comments pass
* fix: remove dry run script
* fix(db): chain authz migration after api_key expires_at revision
Both 7c8d9e0f1a2b and f6b3ce6845d4 pointed at mb01b2c3d4e5, causing
MultipleHeads on startup. Authz now revises f6b3ce6845d4.
---------
Co-authored-by: Eric Hare <ericrhare@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
11 KiB
AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
Project Overview
Langflow is a visual workflow builder for AI-powered agents. It has a Python/FastAPI backend, React/TypeScript frontend, and a lightweight executor CLI (lfx).
Prerequisites
- Python: 3.10-3.13
- uv: >=0.4 (Python package manager)
- Node.js: >=20.19.0 (v22.12 LTS recommended)
- npm: v10.9+
- make: For build coordination
Common Commands
Development Setup
make init # Install all dependencies + pre-commit hooks
make run_cli # Build and run Langflow (http://localhost:7860)
make run_clic # Clean build and run (use when frontend issues occur)
Development Mode (Hot Reload)
make backend # FastAPI on port 7860 (terminal 1)
make frontend # Vite dev server on port 3000 (terminal 2)
For component development, enable dynamic loading:
LFX_DEV=1 make backend # Load all components dynamically
LFX_DEV=mistral,openai make backend # Load only specific modules
Code Quality
make format_backend # Format Python (ruff) - run FIRST before lint
make format_frontend # Format TypeScript (biome)
make format # Both
make lint # mypy type checking
Testing
make unit_tests # Backend unit tests (pytest, parallel)
make unit_tests async=false # Sequential tests
uv run pytest path/to/test.py # Single test file
uv run pytest path/to/test.py::test_name # Single test
make test_frontend # Jest unit tests
make tests_frontend # Playwright e2e tests
Database Migrations
make alembic-revision message="Description" # Create migration
make alembic-upgrade # Apply migrations
make alembic-downgrade # Rollback one version
Architecture
Monorepo Structure
src/
├── backend/
│ ├── base/langflow/ # Core backend package (langflow-base)
│ │ ├── api/ # FastAPI routes (v1/, v2/)
│ │ ├── components/ # Built-in Langflow components
│ │ ├── services/ # Service layer (auth, database, cache, etc.)
│ │ ├── graph/ # Flow graph execution engine
│ │ └── custom/ # Custom component framework
│ └── tests/ # Backend tests
├── frontend/ # React/TypeScript UI
│ └── src/
│ ├── components/ # UI components
│ ├── stores/ # Zustand state management
│ └── icons/ # Component icons
└── lfx/ # Lightweight executor CLI
Key Packages
- langflow: Main package with all integrations
- langflow-base: Core framework (api, services, graph engine)
- lfx: Standalone CLI for running flows (
lfx serve,lfx run)
Service Layer
Backend services in src/backend/base/langflow/services/:
auth/- Authenticationauthorization/- Authorization (RBAC) plugin layer — see belowdatabase/- SQLAlchemy models and migrationscache/- Caching layerstorage/- File storagetracing/- Observability integrations
Authorization (RBAC)
Authorization is a pluggable layer separate from authentication:
- OSS ships the interface (
BaseAuthorizationServiceinlfx) + a pass-through implementation (LangflowAuthorizationService) + theauthz_*andcasbin_ruleDB schema + route guards. - Enterprise registers a Casbin-backed implementation via the
lfx.servicesentry pointauthorization_serviceinlfx.toml(same pattern as the SSOauth_service). The enterprise plugin readsauthz_*admin tables and writes compiled Casbin rules tocasbin_rule.
Default is off: LANGFLOW_AUTHZ_ENABLED=false. When enabled with only the OSS stub registered, every check returns allow — the stub is a no-op so routes stay wired and audit rows still flow. Real allow/deny requires the enterprise plugin.
Route guards live in langflow.services.authorization.utils:
ensure_flow_permission(user, FlowAction.*, flow_id=..., flow_user_id=..., workspace_id=..., folder_id=...)— single-flow CRUD + executeensure_deployment_permission(user, DeploymentAction.*, deployment_id=..., deployment_user_id=..., workspace_id=..., project_id=...)ensure_project_permission(user, ProjectAction.*, project_id=..., project_user_id=..., workspace_id=...)ensure_knowledge_base_permission(user, KnowledgeBaseAction.*, kb_name=..., kb_user_id=...)ensure_variable_permission(user, VariableAction.*, variable_id=..., variable_user_id=...)ensure_file_permission(user, FileAction.*, file_id=..., file_user_id=...)ensure_share_permission(user, ShareAction.*, share_id=..., share_user_id=...)filter_visible_resources(user, resource_type=..., candidates=..., act=...)— list-endpoint filter; safe no-op in OSS
The Casbin request shape is (subject, domain, object, action):
- subject =
user:{uuid} - domain =
project:{uuid}→workspace:{uuid}→*(resolved by_resolve_flow_domain; the more specific domain wins so project-scoped grants match directly while workspace-scoped grants still flow down via Casbing2inheritance) - object =
flow:{uuid}/deployment:{uuid}/project:{uuid}/flow:*/ etc. - action =
read/write/create/delete/execute/deploy
Share-aware fetch (Phase 3): route fetch helpers (_read_flow, get_flow_by_id_or_endpoint_name, get_deployment, project reads in projects.py, v2 file fetcher) branch on BaseAuthorizationService.supports_cross_user_fetch(). The OSS pass-through reports False so the existing owner-scoped queries are preserved — enabling LANGFLOW_AUTHZ_ENABLED=true without an enterprise plugin cannot widen visibility. Enterprise plugins set SUPPORTS_CROSS_USER_FETCH=True so resources load by id alone and ensure_*_permission decides access; route handlers can convert a plugin-deny HTTPException(403) to HTTPException(404) via langflow.services.authorization.fetch.deny_to_404 to preserve UUID privacy.
Share CRUD API (Phase 3): /api/v1/authz/shares provides POST / GET / PATCH / DELETE on authz_share rows. The handler enforces an OSS floor (resource owner or superuser may administer shares for that resource) so the OSS pass-through cannot let a non-owner mint share rows. Each write fires BaseAuthorizationService.invalidate_user / invalidate_all so an enterprise enforcer can drop cached policy. Audit rows are written via audit_decision with share:create / share:update / share:delete actions.
Audit query API (Phase 4): GET /api/v1/authz/audit (superuser-only) exposes a paginated, filterable view of authz_audit_log. Supports user_id, resource_type, resource_id, action, result, since, until filters; page size capped at 200.
Default role catalog (Phase 4): the seed migration 8d3a1f9c2e0b_seed_authz_system_roles inserts the three built-in is_system=True roles (viewer / developer / admin) with "{resource}:{action}" permission slugs. OSS does not interpret these — they exist so an enterprise plugin's PolicySync has a stable bootstrap source.
Component Development
Components live in src/backend/base/langflow/components/. To add a new component:
- Create component class inheriting from
Component - Define
display_name,description,icon,inputs,outputs - Add to
__init__.py(alphabetical order) - Run with
LFX_DEV=1 make backendfor hot reload
IMPORTANT: Changing a component's class name is a breaking change and should never be done. The class name serves as an identifier used to match components in saved flows and to flag them for updates in the UI. Renaming it will break existing flows that use that component.
Component Structure
from langflow.custom import Component
from langflow.io import MessageTextInput, Output
class MyComponent(Component):
display_name = "My Component"
description = "What it does"
icon = "component-icon" # Lucide icon name or custom
inputs = [
MessageTextInput(name="input_value", display_name="Input"),
]
outputs = [
Output(display_name="Output", name="output", method="process"),
]
def process(self) -> Message:
# Component logic
return Message(text=self.input_value)
Component Testing
Tests go in src/backend/tests/unit/components/. Use base classes:
ComponentTestBaseWithClient- Components needing API accessComponentTestBaseWithoutClient- Pure logic components
Required fixtures: component_class, default_kwargs, file_names_mapping
Frontend Development
- React 19 + TypeScript + Vite
- Zustand for state management
- @xyflow/react for graph visualization
- Tailwind CSS for styling
Custom Icons
- Create SVG component in
src/frontend/src/icons/YourIcon/ - Export with
forwardRefandisDarkprop support - Add to
lazyIconImports.ts - Set
icon = "YourIcon"in Python component
Testing Notes
@pytest.mark.api_key_required- Tests requiring external API keys@pytest.mark.no_blockbuster- Skip blockbuster plugin- Database tests may fail in batch but pass individually
- Pre-commit hooks require
uv run git commit - Always use
uv runwhen running Python commands - When running tests inside a sub-package (e.g.
langflow-base,lfx), sync that package's dev group first:uv sync --group dev --package langflow-base. The defaultuv synconly resolves the top-level workspace and may leave dev-only test deps (e.g.fakeredis) uninstalled.
Graph Testing Pattern
Proper Graph tests follow this pattern:
- Build graph with connected components
- Connect them via
.set()calls - Call
async_startand iterate over the results - Validate the results
Testing Best Practices
- Avoid mocking in tests when possible
- Prefer real integrations for more reliable tests
Version Management
make patch v=1.5.0 # Update version across all packages
This updates: pyproject.toml, src/backend/base/pyproject.toml, src/frontend/package.json
Pre-commit Workflow
Pre-commit hooks run ruff and biome automatically on git commit, so manual
formatting is not required. To avoid an extra commit cycle when you have many
changes:
- Run
make format_backendonce before staging - fixes most ruff issues up front. - Run
uv run git commit(theuv runensures pre-commit finds the right Python). - If you touched backend code, run
make unit_testslocally for faster feedback than CI.
Pull Request Guidelines
- Follow semantic commit conventions
- Reference any issues fixed (e.g.,
Fixes #1234) - Ensure all tests pass before submitting
Documentation
Documentation uses Docusaurus and lives in docs/:
cd docs
yarn install
yarn start # Dev server on port 3000 (prompts for 3001 if 3000 is in use)