Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions flow/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
from frappe import _
from werkzeug.wrappers import Response

from flow.permissions import assert_flow_access

if TYPE_CHECKING:
from flow.flow.doctype.flow_run.flow_run import FlowRun
from flow.lib.agent import Event
Expand All @@ -28,6 +30,7 @@ def start_run(
) -> dict[str, Any] | Response:
"""Start a new turn. Creates a session if none is given. `attachments` are uploaded File
names whose text is injected into this turn. With `stream=True`, returns SSE."""
assert_flow_access()
if not isinstance(input, str) or not input.strip():
frappe.throw(_("Input is required."), title=_("Invalid Input"))

Expand All @@ -45,6 +48,8 @@ def resume_run(
run_name: str, answers: dict[str, Any] | str, stream: bool | str = False
) -> dict[str, Any] | Response:
"""Resume a Paused run. `answers` maps each question.key to the user's answer. With `stream=True`, returns SSE."""
assert_flow_access()

from flow.lib.session import assert_run_owner, load_session

parsed_answers = _parse_answers(answers)
Expand All @@ -66,6 +71,8 @@ def resume_run(
def stop_run(run_name: str) -> dict[str, str]:
"""Stop a run at the user's request: terminate a Paused run so the agent won't continue,
or finalize a Running one whose SSE stream the client has aborted."""
assert_flow_access()

from flow.lib.session import assert_run_owner

if not isinstance(run_name, str) or not run_name.strip():
Expand All @@ -83,6 +90,7 @@ def recover_session(session: str) -> dict[str, int]:
"""Fail any Running run on session (re)load. The client that owned the stream is
gone, so the run is abandoned; clearing it here unblocks the next turn instead of
waiting for the stale-run timeout on the next send."""
assert_flow_access()
if not isinstance(session, str) or not session.strip():
frappe.throw(_("Session is required."), title=_("Invalid Session"))

Expand All @@ -109,6 +117,8 @@ def submit_feedback(run_name: str, rating: str, comment: str | None = None) -> d
"""Record thumbs feedback on a finished run, or clear it with rating "None". A
thumbs-down comment is stored as shared agent memory when the agent has memory
enabled (a no-op otherwise). Clearing the rating leaves any saved memory intact."""
assert_flow_access()

from flow.lib.session import assert_run_owner
from flow.memory.memory import save_feedback_memory

Expand Down Expand Up @@ -148,6 +158,7 @@ def submit_feedback(run_name: str, rating: str, comment: str | None = None) -> d
def get_agent_tools(agent: str) -> dict[str, bool]:
"""Map an agent's tool slugs to whether each needs confirmation, so the panel can
classify tool calls (approval vs. inline)"""
assert_flow_access()
if not isinstance(agent, str) or not agent.strip():
return {}

Expand All @@ -168,6 +179,7 @@ def attach_file(file: str) -> dict[str, Any]:
"""Validate and extract an uploaded File for use as a chat attachment. Errors
(unsupported type, unreadable, not owned) surface here, at upload time. The
extracted text is staged in cache; the attachment row is written on send."""
assert_flow_access()
if not isinstance(file, str) or not file.strip():
frappe.throw(_("File is required."), title=_("Invalid Attachment"))

Expand Down
5 changes: 5 additions & 0 deletions flow/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@
# License: MIT. See LICENSE

from flow.knowledge.extract import FILE_EXTENSIONS
from flow.permissions import has_flow_access


def boot_session(bootinfo):
# Single source of truth for file types the ingest pipeline can extract
bootinfo.flow_supported_file_types = sorted(FILE_EXTENSIONS)
# Lets the panel skip mounting for users who may not use Flow. Presentation only —
# `app_include_js` is assembled statically per site (frappe/www/desk.py), so the
# bundle still ships to everyone. The real gate is flow.permissions on the API.
bootinfo.flow_enabled = has_flow_access()
4 changes: 2 additions & 2 deletions flow/flow/doctype/flow_agent/flow_agent.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-05-24 00:00:00",
"modified": "2026-08-06 00:00:00.000000",
"modified_by": "Administrator",
"module": "Flow",
"name": "Flow Agent",
Expand All @@ -124,7 +124,7 @@
},
{
"read": 1,
"role": "All"
"role": "Flow User"
}
],
"row_format": "Dynamic",
Expand Down
23 changes: 15 additions & 8 deletions flow/flow/doctype/flow_agent/test_flow_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from flow.lib.agent import Agent
from flow.lib.model import ChatResponse, Model
from flow.permissions import FLOW_USER_ROLE, ensure_flow_role
from flow.tools.builtins import sync_builtin_tools


Expand Down Expand Up @@ -241,14 +242,20 @@ def setUp(self):
self.allowed = frappe.get_doc(_model(title="Allowed Model")).insert()
self.restricted = frappe.get_doc(_model(title="Restricted Model")).insert()
self.agent_doc = frappe.get_doc(_agent(self.restricted.name)).insert()
self.user = frappe.get_doc(
{
"doctype": "User",
"email": "model-perm@example.com",
"first_name": "Model Perm",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
ensure_flow_role()
if not frappe.db.exists("User", "model-perm@example.com"):
frappe.get_doc(
{
"doctype": "User",
"email": "model-perm@example.com",
"first_name": "Model Perm",
"send_welcome_email": 0,
}
).insert(ignore_permissions=True)
self.user = frappe.get_doc("User", "model-perm@example.com")
# These tests are about User Permission narrowing *which* model is usable, so the
# user must first clear the doctype-level read check — that now needs Flow User.
self.user.add_roles(FLOW_USER_ROLE)

def tearDown(self):
frappe.set_user("Administrator")
Expand Down
4 changes: 2 additions & 2 deletions flow/flow/doctype/flow_model/flow_model.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
}
],
"links": [],
"modified": "2026-07-04 00:00:00.000000",
"modified": "2026-08-06 00:00:00.000000",
"modified_by": "Administrator",
"module": "Flow",
"name": "Flow Model",
Expand All @@ -105,7 +105,7 @@
},
{
"read": 1,
"role": "All"
"role": "Flow User"
}
],
"row_format": "Dynamic",
Expand Down
4 changes: 2 additions & 2 deletions flow/flow/doctype/flow_run/flow_run.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@
],
"index_web_pages_for_search": 0,
"links": [],
"modified": "2026-05-23 00:00:00",
"modified": "2026-08-06 00:00:00.000000",
"modified_by": "Administrator",
"module": "Flow",
"name": "Flow Run",
Expand All @@ -215,7 +215,7 @@
{
"if_owner": 1,
"read": 1,
"role": "All"
"role": "Flow User"
}
],
"row_format": "Dynamic",
Expand Down
4 changes: 2 additions & 2 deletions flow/flow/doctype/flow_session/flow_session.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"link_fieldname": "session"
}
],
"modified": "2026-05-26 00:00:00",
"modified": "2026-08-06 00:00:00.000000",
"modified_by": "Administrator",
"module": "Flow",
"name": "Flow Session",
Expand All @@ -109,7 +109,7 @@
"delete": 1,
"if_owner": 1,
"read": 1,
"role": "All",
"role": "Flow User",
"write": 1
}
],
Expand Down
10 changes: 9 additions & 1 deletion flow/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ def _flow_panel_asset(filename: str) -> str:
},
}

after_migrate = ["flow.assistant.sync_builtin_assistant"]
# The Flow User role gates access to the assistant; create it before anything that
# might reference it. Both hooks call the same idempotent function so a fresh install
# and an existing site converge on the same state.
after_install = "flow.permissions.ensure_flow_role"

after_migrate = [
"flow.permissions.ensure_flow_role",
"flow.assistant.sync_builtin_assistant",
]

extend_bootinfo = "flow.boot.boot_session"
3 changes: 2 additions & 1 deletion flow/patches.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ flow.patches.rename_ai_to_flow

[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
flow.patches.remove_frontend_tooling
flow.patches.remove_frontend_tooling
flow.patches.grant_flow_role
84 changes: 84 additions & 0 deletions flow/patches/grant_flow_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import frappe

from flow.permissions import FLOW_USER_ROLE, ensure_flow_role

"""Grandfather existing users onto the new Flow User role.

Before this patch every logged-in user could drive Flow; the role did not exist
and the doctypes granted access to `All`. Introducing the gate would silently
lock out everyone, including the admins who need to hand access out, so this
preserves the status quo once: every enabled System Manager keeps access, and
the site owner revokes individually afterwards.

Only System Managers are granted — not literally everyone who could reach the
API before — because the pre-gate audience included Website Users, and
reinstating that would defeat the point of the change.

Granting a role saves the User doc, which re-runs *every* installed app's User
validation. On a real site those reject for reasons that have nothing to do with
Flow — a user whose existing role mix another app forbids, say. A backfill must
never abort `bench migrate` over that, so each grant is isolated in its own
savepoint: a rejection rolls back that one user and the sweep continues. Skipped
users are listed at the end and can be granted by hand.

Fresh installs never run this: `install_app` marks all patches complete without
executing them (frappe/installer.py), so a new site starts with nobody
grandfathered in.
"""


def execute():
ensure_flow_role()

system_managers = frappe.get_all(
"Has Role",
filters={"role": "System Manager", "parenttype": "User"},
pluck="parent",
distinct=True,
)
if not system_managers:
return

enabled = set(
frappe.get_all(
"User",
filters={"name": ["in", system_managers], "enabled": 1, "user_type": "System User"},
pluck="name",
)
)
enabled.discard("Administrator") # bypasses the gate anyway
if not enabled:
return

already = set(
frappe.get_all(
"Has Role",
filters={"role": FLOW_USER_ROLE, "parenttype": "User", "parent": ["in", list(enabled)]},
pluck="parent",
)
)

granted: list[str] = []
skipped: list[tuple[str, str]] = []
for index, user in enumerate(sorted(enabled - already)):
savepoint = f"flow_grant_role_{index}"
frappe.db.savepoint(savepoint)
try:
# add_roles goes through the User doc so role-change side effects still fire.
frappe.get_doc("User", user).add_roles(FLOW_USER_ROLE)
except Exception as e:
frappe.db.rollback(save_point=savepoint)
# The rejecting app queues a dialog; drop it so it cannot surface later.
frappe.clear_messages()
skipped.append((user, str(e)[:200]))
else:
frappe.db.release_savepoint(savepoint)
granted.append(user)

if skipped:
listed = "\n".join(f"- {user}: {reason}" for user, reason in skipped)
print(
f"Flow: granted '{FLOW_USER_ROLE}' to {len(granted)} user(s). "
f"{len(skipped)} could not be granted and will not have Flow access:\n{listed}\n"
f"Grant the role manually (User > Roles) if any of them should keep it."
)
57 changes: 57 additions & 0 deletions flow/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# License: MIT. See LICENSE

"""Access control for Flow.

Flow is gated by a single role, `Flow User`. A user either may use the
assistant or may not — there are no sub-feature toggles, because an agent
already acts strictly within the running user's own permissions (see
`flow.utils.safe_exec`, which strips `ignore_permissions` and omits raw SQL
entirely). So the only question worth asking is *who*, and a role answers it
the way any other Frappe feature would.

The gate is deliberately the role alone: a System Manager without `Flow User`
is refused, so an admin can hand out access without handing out their own.
`Administrator` bypasses `frappe.only_for` upstream and cannot be excluded.

Enforcement belongs on the whitelisted API (`flow.api`) — that is the
untrusted boundary. Server-side Python (`flow.lib.session`, triggers) is
trusted and stays ungated, or a trigger firing as a low-privilege `run_as`
user would break.
"""

from __future__ import annotations

import frappe

FLOW_USER_ROLE = "Flow User"


def assert_flow_access() -> None:
"""Raise unless the current user may use Flow. Call first in every whitelisted endpoint."""
frappe.only_for(FLOW_USER_ROLE, message=True)


def has_flow_access(user: str | None = None) -> bool:
"""Whether `user` (default: session user) may use Flow. Used for UI hints, never as the gate."""
if (user or frappe.session.user) == "Administrator":
return True
return FLOW_USER_ROLE in frappe.get_roles(user)


def ensure_flow_role() -> None:
"""Create the Flow User role if missing. Idempotent — safe on install and every migrate.

The existence check is an optimisation, not the guarantee: two lifecycle hooks racing
could both pass it. `ignore_if_duplicate` makes the insert itself the safe operation.
"""
if frappe.db.exists("Role", FLOW_USER_ROLE):
return
frappe.get_doc(
{
"doctype": "Role",
"role_name": FLOW_USER_ROLE,
# Flow lives in the desk, so the role must carry desk access to be useful.
"desk_access": 1,
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
).insert(ignore_permissions=True, ignore_if_duplicate=True)
Loading