Files
langflow/AGENTS.md
Eric Hare aafce557aa feat(authz): admin APIs, share-aware build_flow, and base service additions (#13371)
* feat(authz): add roles, role-assignments, teams, and me/permissions APIs

Completes the OSS admin surface for RBAC: full CRUD on authz_role,
authz_role_assignment, authz_team, and authz_team_member tables, plus a
per-user effective-permissions endpoint backing the frontend permission gate.
Plugin (enterprise Casbin) is invalidated on every write so the next enforce
sees the change.

New endpoints:
* GET/POST/PATCH/DELETE /api/v1/authz/roles -- custom-role CRUD with parent
  cycle detection, system-role protection, and FK-aware delete (409 when
  assignments still reference the role).
* GET/POST/DELETE /api/v1/authz/role-assignments -- list filterable by
  user/role/domain; superuser-only for write, self-read allowed.
* GET/POST/PATCH/DELETE /api/v1/authz/teams + GET/POST/DELETE
  /api/v1/authz/teams/{id}/members -- backs the share-with-team flow and
  the team admin UI.
* POST /api/v1/authz/me/permissions -- returns per-resource allowed actions
  for the current user (capped at 500 ids), backs the FE permission gate.

Base service additions (lfx.services.authorization.base):
* list_visible_resource_ids -- plugin SQL prefilter for list endpoints; OSS
  returns None ("no prefilter, fall through to filter_visible_resources").
* get_effective_permissions -- default impl uses batch_enforce so plugins
  inherit it for free; Casbin plugin can override for a tighter query.

* fix(authz): make build_flow share-aware so shared flows can be run by non-owners

The build_flow handler was loading flows with an owner-OR-public SQL filter,
short-circuiting the ensure_flow_permission(EXECUTE) check on private flows
shared with the current user. The existing in-code comment flagged this as a
Phase 3 prerequisite for cross-user build support.

Switch to the share-aware load helper (_read_flow / authorized_or_owner_scoped):
* When the registered authorization plugin signals SUPPORTS_CROSS_USER_FETCH
  (enterprise Casbin), the row loads by id alone and the plugin decides via
  enforce(). OSS pass-through keeps the historical owner-only behavior.
* PUBLIC flows are still buildable by any authenticated user in both modes
  (preserved via the explicit access_type=PUBLIC fallback).
* Plugin deny is translated to 404 (not 403) via deny_to_404 so the response
  is indistinguishable from "flow does not exist" and the caller cannot
  enumerate UUIDs by probing for 403 vs 404.

* test(authz): cover new admin routes and share-aware build_flow

Pins the security floor and validation behavior added in the prior commits so
they don't silently regress. Uses the existing fake async session + stub authz
service pattern from test_authz_share_routes.py.

test_authz_admin_routes.py (17 tests):
* /authz/roles -- superuser gate on create, persist + invalidate_all, 409 on
  name conflict, system-role protection on update + delete, self-parent and
  parent-cycle rejection, 409 when deleting a role with active assignments.
* /authz/role-assignments -- non-superuser blocked from listing other users'
  assignments; self-list allowed; 404 when user_id is unknown; create
  invokes invalidate_user(target).
* /authz/teams -- superuser gate on create, add_member invokes
  invalidate_user(target), duplicate add returns 409.
* /authz/me/permissions -- correct per-resource shape, 400 when over 500
  ids, empty request returns empty.

test_chat_build_flow_authz.py (5 tests):
* Owner happy path preserved.
* Plugin 403 deny converted to 404 (UUID privacy).
* Unknown flow + no PUBLIC fallback -> 404.
* Share-aware load miss + PUBLIC fallback hits -> success.
* Non-403 errors (e.g. 500) pass through with status preserved.

Total: 22 new tests, all passing locally.

* fix(authz): normalize, dedupe, and cap the actions list on /authz/me/permissions

The resource_ids cap (500) bounded one side of the cartesian product, but the
actions list was unbounded. A client could submit actions=["read"] * 100000
and trigger a 50M-entry batch_enforce, plus the same action in different
casings ("READ", "read") would have been treated as distinct entries.

Add a field_validator on EffectivePermissionsRequest.actions that:
* strips whitespace and lowercases each entry
* de-duplicates while preserving order
* caps the result at 10 unique actions (headroom over the 6 known actions:
  read, write, execute, delete, create, manage)
* returns None when the normalized list is empty so the handler still falls
  back to _DEFAULT_ACTIONS

Over-cap requests raise ValidationError (HTTP 422 at the request boundary),
which matches Pydantic conventions for input shape problems and differs from
the resource_ids cap (HTTP 400 in the handler) by design -- bound-violation
on list size is structurally a validation error, not application logic.

6 new tests: normalization+dedupe, empty-collapses-to-None, over-cap rejection,
50-entry dedupe stays under cap, None stays None, handler sees normalized list.

* fix(authz): default /authz/role-assignments list to caller's own assignments

The gate `if user_id is None or user_id != current_user.id` required superuser
whenever user_id was omitted, so any non-superuser calling
GET /authz/role-assignments with no query params got 403 even though the
docstring promised "Users may query their own assignments".

Resolve user_id to current_user.id when omitted, then only gate superuser when
the resolved id differs from the caller. The query is now always filtered by
the resolved user_id, so admins doing cross-user lookups make one call per
user instead of relying on an unscoped see-all.

Adds test_list_assignments_no_user_id_defaults_to_self pinning the new
behavior. The other two assignment-list tests (self with explicit user_id,
non-superuser blocked from another user) remain unchanged and still pass.

* fix(authz): use model_fields_set so PATCH /authz/roles can clear nullable fields

The "if payload.<field> is not None" guards made it impossible to clear
description or parent_role_id via PATCH -- an explicit "description": null
in the body was indistinguishable from omitting the field, leaving the old
value in place.

Switch to presence checks via payload.model_fields_set:
* description: nullable on the DB side, so an explicit null clears the field.
* parent_role_id: nullable + SET NULL on cascade; null removes the parent.
* name: NOT NULL + unique on the DB side. The presence check applies, but
  an explicit null is rejected at the boundary with HTTP 400 so the caller
  sees a clear error instead of an opaque 409 "Name conflict" from the
  IntegrityError catch block below.
* permissions: nullable=False (default_factory=list) on the DB side. An
  empty list is the natural "clear"; null is rejected with HTTP 400.

6 new tests: clear description via null, clear parent via null, omitted
fields untouched, reject null name, clear permissions via empty list,
reject null permissions.

Adds _make_role_row helper so SimpleNamespace fakes carry every field
that RoleRead.model_validate expects (workspace_id, created_at, created_by).

* fix(authz): use model_fields_set so PATCH /authz/teams can clear description

The "if payload.description is not None" guard made the description field
unclearable via PATCH -- an explicit "description": null was indistinguishable
from omitting the field, leaving the old value in place.

Switch to a presence check via payload.model_fields_set so null clears the
field, while omission still leaves the row alone. The DB column is nullable,
so None is a legitimate value here.

Scope intentionally narrow: team_name and adom_name are NOT NULL on the DB
side (null would crash on commit) and is_active is bool (null doesn't model
any state), so they keep the existing is-not-None guards.

Adds 2 tests: clear via explicit null, omission leaves value untouched.
Adds _make_team_row helper for parity with _make_role_row.

* fix(authz): validate batch_enforce result length in get_effective_permissions

The default get_effective_permissions impl built `requests` as a cartesian
product of resource_ids x actions, called batch_enforce, then sliced the
flat result back into per-resource action lists. If a plugin's batch_enforce
returned the wrong number of results -- contract violation -- the slicing
silently produced incorrect mappings:

* too-short flat: later resources got `[]` (out-of-bounds slice returns empty
  in Python) instead of their real permissions
* too-long flat: tail entries silently dropped

The inner `zip(..., strict=True)` only caught the per-slice mismatch when a
slice happened to be too short, not the global length mismatch.

Validate `len(flat) == len(requests)` immediately after the call and raise
ValueError with expected/actual counts so plugin authors find the contract
break at the first wrong call, not via mysteriously-empty permission lists.

Adds two lfx tests:
* default impl returns full action list per resource (no enforcement)
* mocked batch_enforce returning a wrong-sized list raises ValueError

* fix: remove EE references

* fix: harden authz admin APIs based on PR review

- Pin permission slug format to canonical <resource>:<action> and validate
  RoleCreate/RoleUpdate at the API boundary so bad slugs cannot land in
  authz_role.permissions and silently fail policy sync.
- Tighten RoleAssignmentCreate: domain_type is a Literal and a model
  validator enforces domain_id consistency (required for org/workspace/
  project, forbidden for global).
- Restore _resolve_casbin_domain re-export for plugin backward compat.
- Audit-log policy-changing admin writes (role CRUD, role-assignment
  create/delete, team CRUD, team membership add/remove) so privilege
  grants are visible alongside enforcement decisions.
- Invalidate authorization cache when a team's adom_name or is_active
  changes; pure display edits still skip the flush.

* review feedback

* [autofix.ci] apply automated fixes

* fix(authz): harden build_flow and expose authz_enabled on config

Block non-owners from overriding flow graph data on shared builds,
revert unrelated component_index.json churn, and surface
LANGFLOW_AUTHZ_ENABLED on public and authenticated config responses
for EE UI gating.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: himavarshagoutham <himavarshajan17@gmail.com>
Co-authored-by: Himavarsha <40851462+HimavarshaVS@users.noreply.github.com>
2026-05-28 17:45:41 +00:00

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/ - Authentication
  • authorization/ - Authorization (RBAC) plugin layer — see below
  • database/ - SQLAlchemy models and migrations
  • cache/ - Caching layer
  • storage/ - File storage
  • tracing/ - Observability integrations

Authorization (RBAC)

Authorization is a pluggable layer separate from authentication:

  • OSS ships the interface (BaseAuthorizationService in lfx) + a pass-through implementation (LangflowAuthorizationService) + the authz_* and casbin_rule DB schema + route guards.
  • Implementations register via the lfx.services entry point authorization_service in lfx.toml (same pattern as the SSO auth_service). A registered plugin reads the authz_* admin tables and writes compiled rules to casbin_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 a registered authorization 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 + execute
  • ensure_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 enforcement 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 plugin-side role inheritance)
  • 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 a registered plugin cannot widen visibility. 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 a registered 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 a registered plugin's policy sync has a stable bootstrap source.

Component Development

Components live in src/backend/base/langflow/components/. To add a new component:

  1. Create component class inheriting from Component
  2. Define display_name, description, icon, inputs, outputs
  3. Add to __init__.py (alphabetical order)
  4. Run with LFX_DEV=1 make backend for 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 access
  • ComponentTestBaseWithoutClient - 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

  1. Create SVG component in src/frontend/src/icons/YourIcon/
  2. Export with forwardRef and isDark prop support
  3. Add to lazyIconImports.ts
  4. 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 run when 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 default uv sync only 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:

  1. Build graph with connected components
  2. Connect them via .set() calls
  3. Call async_start and iterate over the results
  4. 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:

  1. Run make format_backend once before staging - fixes most ruff issues up front.
  2. Run uv run git commit (the uv run ensures pre-commit finds the right Python).
  3. If you touched backend code, run make unit_tests locally for faster feedback than CI.

Pull Request Guidelines

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)