Skip to content

Commit 23f5ea9

Browse files
committed
feat(authz): complete release 1.12 RBAC deliverables
1 parent d3392bb commit 23f5ea9

69 files changed

Lines changed: 2901 additions & 127 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.secrets.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2243,7 +2243,7 @@
22432243
"filename": "src/backend/tests/unit/services/variable/test_service.py",
22442244
"hashed_secret": "30abc0a833efea23496b4d226fffa2f90c0855c0",
22452245
"is_verified": false,
2246-
"line_number": 52,
2246+
"line_number": 53,
22472247
"is_secret": false
22482248
}
22492249
],
@@ -7222,5 +7222,5 @@
72227222
}
72237223
]
72247224
},
7225-
"generated_at": "2026-07-22T04:00:31Z"
7225+
"generated_at": "2026-07-22T21:43:55Z"
72267226
}

src/backend/base/langflow/agentic/helpers/validation.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,9 @@ async def _execute_output_methods_for_validation(cc_instance) -> str | None:
241241
return None
242242

243243
try:
244+
from lfx.services.model_provider_policy import ModelProviderPolicyPurpose
245+
246+
cc_instance.require_model_provider_policy(ModelProviderPolicyPurpose.USE)
244247
await cc_instance._build_results() # noqa: SLF001 — sandbox bypass of send_error/tracing
245248
except ValidationError as exc:
246249
return _format_validation_error(exc)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Add first-class actor identity to authorization audit rows.
2+
3+
Phase: EXPAND
4+
Revision ID: a6c4e2f8b1d3
5+
Revises: d19e7b3c5a42
6+
Create Date: 2026-07-22 00:00:00.000000
7+
8+
Both columns are nullable for N-1 compatibility and to preserve legacy rows.
9+
``actor_id`` intentionally has no foreign key: audit attribution must survive
10+
deletion of an API key or other credential record.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import TYPE_CHECKING
16+
17+
import sqlalchemy as sa
18+
import sqlmodel
19+
from alembic import op
20+
from langflow.utils import migration
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Sequence
24+
25+
# revision identifiers, used by Alembic.
26+
revision: str = "a6c4e2f8b1d3" # pragma: allowlist secret
27+
down_revision: str | None = "d19e7b3c5a42" # pragma: allowlist secret
28+
branch_labels: str | Sequence[str] | None = None
29+
depends_on: str | Sequence[str] | None = None
30+
31+
TABLE_NAME = "authz_audit_log"
32+
ACTOR_INDEX = "ix_authz_audit_log_actor_timestamp"
33+
ACTOR_TYPE_INDEX = "ix_authz_audit_log_actor_type_timestamp"
34+
35+
36+
def _index_exists(conn, index_name: str) -> bool:
37+
return index_name in {index["name"] for index in sa.inspect(conn).get_indexes(TABLE_NAME)}
38+
39+
40+
def upgrade() -> None:
41+
conn = op.get_bind()
42+
if not migration.table_exists(TABLE_NAME, conn):
43+
return
44+
45+
with op.batch_alter_table(TABLE_NAME, schema=None) as batch_op:
46+
if not migration.column_exists(TABLE_NAME, "actor_type", conn):
47+
batch_op.add_column(sa.Column("actor_type", sqlmodel.sql.sqltypes.AutoString(), nullable=True))
48+
if not migration.column_exists(TABLE_NAME, "actor_id", conn):
49+
batch_op.add_column(sa.Column("actor_id", sa.Uuid(), nullable=True))
50+
51+
# Re-inspect after the batch because SQLite recreates the table.
52+
if not _index_exists(conn, ACTOR_INDEX):
53+
op.create_index(ACTOR_INDEX, TABLE_NAME, ["actor_id", "timestamp"], unique=False)
54+
if not _index_exists(conn, ACTOR_TYPE_INDEX):
55+
op.create_index(ACTOR_TYPE_INDEX, TABLE_NAME, ["actor_type", "timestamp"], unique=False)
56+
57+
58+
def downgrade() -> None:
59+
conn = op.get_bind()
60+
if not migration.table_exists(TABLE_NAME, conn):
61+
return
62+
63+
for index_name in (ACTOR_INDEX, ACTOR_TYPE_INDEX):
64+
if _index_exists(conn, index_name):
65+
op.drop_index(index_name, table_name=TABLE_NAME)
66+
67+
with op.batch_alter_table(TABLE_NAME, schema=None) as batch_op:
68+
if migration.column_exists(TABLE_NAME, "actor_id", conn):
69+
batch_op.drop_column("actor_id")
70+
if migration.column_exists(TABLE_NAME, "actor_type", conn):
71+
batch_op.drop_column("actor_type")
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Synchronize denormalized flow workspace ids from their projects.
2+
3+
Phase: MIGRATE
4+
Revision ID: b7d5f9a3c2e4
5+
Revises: a6c4e2f8b1d3
6+
Create Date: 2026-07-22 00:00:00.000000
7+
8+
Project membership is the authorization source of truth. Older rows could
9+
carry a caller-supplied ``flow.workspace_id`` that disagreed with the owning
10+
folder, so repair every project-scoped row before workspace grants are used as
11+
a SQL list prefilter.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import TYPE_CHECKING
17+
18+
import sqlalchemy as sa
19+
from alembic import op
20+
from langflow.utils import migration
21+
22+
if TYPE_CHECKING:
23+
from collections.abc import Sequence
24+
25+
revision: str = "b7d5f9a3c2e4" # pragma: allowlist secret
26+
down_revision: str | None = "a6c4e2f8b1d3" # pragma: allowlist secret
27+
branch_labels: str | Sequence[str] | None = None
28+
depends_on: str | Sequence[str] | None = None
29+
30+
31+
def upgrade() -> None:
32+
conn = op.get_bind()
33+
if not (migration.table_exists("flow", conn) and migration.table_exists("folder", conn)):
34+
return
35+
required = (
36+
migration.column_exists("flow", "folder_id", conn)
37+
and migration.column_exists("flow", "workspace_id", conn)
38+
and migration.column_exists("folder", "workspace_id", conn)
39+
)
40+
if not required:
41+
return
42+
43+
conn.execute(
44+
sa.text(
45+
"""
46+
UPDATE flow
47+
SET workspace_id = (
48+
SELECT folder.workspace_id
49+
FROM folder
50+
WHERE folder.id = flow.folder_id
51+
)
52+
WHERE flow.folder_id IS NOT NULL
53+
AND EXISTS (SELECT 1 FROM folder WHERE folder.id = flow.folder_id)
54+
"""
55+
)
56+
)
57+
58+
59+
def downgrade() -> None:
60+
"""Data repair is intentionally irreversible."""

src/backend/base/langflow/api/v1/authz_audit.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from fastapi import APIRouter, Depends, HTTPException, Query
1717
from pydantic import BaseModel
18+
from sqlalchemy import or_
1819
from sqlmodel import col, select
1920

2021
from langflow.api.utils import DbSession
@@ -32,6 +33,8 @@ class AuthzAuditLogRead(BaseModel):
3233

3334
id: UUID
3435
user_id: UUID | None
36+
actor_type: str | None = None
37+
actor_id: UUID | None = None
3538
action: str
3639
resource_type: str | None
3740
resource_id: UUID | None
@@ -58,6 +61,14 @@ async def list_audit_log(
5861
session: DbSession,
5962
_admin: Annotated[User, Depends(get_current_active_superuser)],
6063
user_id: Annotated[UUID | None, Query(description="Filter by acting user id.")] = None,
64+
actor_type: Annotated[
65+
str | None,
66+
Query(description="Filter by credential actor type; ``unknown`` also includes legacy rows without a type."),
67+
] = None,
68+
actor_id: Annotated[
69+
UUID | None,
70+
Query(description="Filter by first-class credential actor UUID."),
71+
] = None,
6172
resource_type: Annotated[
6273
str | None,
6374
Query(description="Filter by resource type slug, e.g. ``flow`` or ``deployment``."),
@@ -88,6 +99,21 @@ async def list_audit_log(
8899
base = select(AuthzAuditLog)
89100
if user_id is not None:
90101
base = base.where(AuthzAuditLog.user_id == user_id)
102+
if actor_type == "unknown":
103+
# Rows written before first-class actor identity was added retain a
104+
# NULL actor_type. The UI presents one "Legacy / unknown" filter, so
105+
# its backend meaning must include both historical NULLs and new
106+
# events explicitly classified as unknown.
107+
base = base.where(
108+
or_(
109+
AuthzAuditLog.actor_type == actor_type,
110+
col(AuthzAuditLog.actor_type).is_(None),
111+
)
112+
)
113+
elif actor_type is not None:
114+
base = base.where(AuthzAuditLog.actor_type == actor_type)
115+
if actor_id is not None:
116+
base = base.where(AuthzAuditLog.actor_id == actor_id)
91117
if resource_type is not None:
92118
base = base.where(AuthzAuditLog.resource_type == resource_type)
93119
if resource_id is not None:
@@ -110,7 +136,14 @@ async def list_audit_log(
110136
total_stmt = select(func.count()).select_from(base.subquery())
111137
total = int((await session.exec(total_stmt)).first() or 0)
112138

113-
page_stmt = base.order_by(col(AuthzAuditLog.timestamp).desc()).offset((page - 1) * size).limit(size)
139+
page_stmt = (
140+
base.order_by(
141+
col(AuthzAuditLog.timestamp).desc(),
142+
col(AuthzAuditLog.id).desc(),
143+
)
144+
.offset((page - 1) * size)
145+
.limit(size)
146+
)
114147
rows = list(await session.exec(page_stmt))
115148

116149
items = [AuthzAuditLogRead.model_validate(row, from_attributes=True) for row in rows]

src/backend/base/langflow/api/v1/authz_route_dependencies.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi import Depends, HTTPException, status
99

1010
from langflow.api.utils import CurrentActiveUser, DbSession
11-
from langflow.api.v1.flows_helpers import _read_flow
11+
from langflow.api.v1.flows_helpers import _canonicalize_flow_destination, _read_flow
1212
from langflow.services.authorization import FlowAction, ensure_flow_permission
1313
from langflow.services.authorization.fetch import deny_to_404
1414
from langflow.services.database.models.flow.model import Flow, FlowCreate
@@ -86,17 +86,20 @@ async def get_authorized_flow_for_delete(
8686
async def require_flow_create_permission(
8787
current_user: CurrentActiveUser,
8888
flow: FlowCreate,
89-
) -> None:
89+
session: DbSession,
90+
) -> tuple[UUID | None, UUID]:
9091
"""Authorize CREATE at the destination workspace/folder before inserting a flow."""
92+
destination = await _canonicalize_flow_destination(session, flow, current_user.id)
9193
await ensure_flow_permission(
9294
current_user,
9395
FlowAction.CREATE,
9496
workspace_id=flow.workspace_id,
9597
folder_id=flow.folder_id,
9698
)
99+
return destination
97100

98101

99102
AuthorizedReadFlow = Annotated[Flow, Depends(get_authorized_flow_for_read)]
100103
AuthorizedWriteFlow = Annotated[Flow, Depends(get_authorized_flow_for_write)]
101104
AuthorizedDeleteFlow = Annotated[Flow, Depends(get_authorized_flow_for_delete)]
102-
RequireFlowCreate = Annotated[None, Depends(require_flow_create_permission)]
105+
RequireFlowCreate = Annotated[tuple[UUID | None, UUID], Depends(require_flow_create_permission)]

0 commit comments

Comments
 (0)