-
Notifications
You must be signed in to change notification settings - Fork 8.9k
feat: add langflow-saas plugin for multi-tenant SaaS capabilities #12884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pachu4u
wants to merge
8
commits into
langflow-ai:main
Choose a base branch
from
pachu4u:claude/clever-mirzakhani-8f22c0
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
bcdffcd
feat: add langflow-saas plugin for multi-tenant SaaS capabilities
pachu4u ff65d4e
[autofix.ci] apply automated fixes
autofix-ci[bot] 8760977
feat(saas): lazy personal-org bootstrap + idempotent migrations
pachu4u 9303987
[autofix.ci] apply automated fixes
autofix-ci[bot] d45c814
feat(saas): org-scoped flows + subscription auto-provisioning
pachu4u 677c1f0
[autofix.ci] apply automated fixes
autofix-ci[bot] 7cb68fa
feat(saas): UserRegistrationMiddleware — provision org on signup
pachu4u 3c0425f
[autofix.ci] apply automated fixes
autofix-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| [alembic] | ||
| # Path to migration scripts, relative to this file. | ||
| script_location = langflow_saas/migrations | ||
|
|
||
| # sqlalchemy.url is intentionally left blank — the env.py reads | ||
| # SAAS_DATABASE_URL (falling back to LANGFLOW_DATABASE_URL) at runtime. | ||
| sqlalchemy.url = | ||
|
|
||
| [loggers] | ||
| keys = root,sqlalchemy,alembic | ||
|
|
||
| [handlers] | ||
| keys = console | ||
|
|
||
| [formatters] | ||
| keys = generic | ||
|
|
||
| [logger_root] | ||
| level = WARN | ||
| handlers = console | ||
| qualname = | ||
|
|
||
| [logger_sqlalchemy] | ||
| level = WARN | ||
| handlers = | ||
| qualname = sqlalchemy.engine | ||
|
|
||
| [logger_alembic] | ||
| level = INFO | ||
| handlers = | ||
| qualname = alembic | ||
|
|
||
| [handler_console] | ||
| class = StreamHandler | ||
| args = (sys.stderr,) | ||
| level = NOTSET | ||
| formatter = generic | ||
|
|
||
| [formatter_generic] | ||
| format = %(levelname)-5.5s [%(name)s] %(message)s | ||
| datefmt = %H:%M:%S |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """langflow-saas: pluggable SaaS / multi-tenancy layer for Langflow.""" |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,270 @@ | ||
| """Billing, plans, usage, and Stripe webhook endpoints. | ||
|
|
||
| Routes: | ||
| GET /api/saas/v1/plans — list active plans (public) | ||
| GET /api/saas/v1/orgs/{org_id}/billing — get subscription details | ||
| POST /api/saas/v1/orgs/{org_id}/billing/checkout — create Stripe checkout session | ||
| GET /api/saas/v1/orgs/{org_id}/usage — get usage summary | ||
| POST /api/saas/v1/billing/webhook — Stripe webhook (no auth, HMAC-verified) | ||
| GET /api/saas/v1/audit — audit log (admin+) | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime, timezone | ||
| from uuid import UUID | ||
|
|
||
| from fastapi import APIRouter, HTTPException, Request, status | ||
| from sqlmodel import select | ||
|
|
||
| from langflow_saas.dependencies import CurrentOrgContext, RequireAdmin, assert_org_match | ||
| from langflow_saas.models import ( | ||
| AuditLog, | ||
| Organization, | ||
| Plan, | ||
| PlanRead, | ||
| Subscription, | ||
| SubscriptionRead, | ||
| UsageMetric, | ||
| UsageRecord, | ||
| UsageSummary, | ||
| ) | ||
| from langflow_saas.services import get_billing_service | ||
| from langflow_saas.settings import get_saas_settings | ||
|
|
||
| router = APIRouter(tags=["Billing & Plans"]) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Plans (public, no auth) | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @router.get("/plans", response_model=list[PlanRead]) | ||
| async def list_plans(): | ||
| """Return all active plans. Safe to call without authentication.""" | ||
| from langflow.services.deps import session_scope | ||
|
|
||
| async with session_scope() as db: | ||
| result = await db.exec(select(Plan).where(Plan.is_active == True)) # noqa: E712 | ||
| return [PlanRead.model_validate(p) for p in result.all()] | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Subscription info | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @router.get("/orgs/{org_id}/billing", response_model=SubscriptionRead | None) | ||
| async def get_subscription(org_id: UUID, ctx: CurrentOrgContext): | ||
| assert_org_match(org_id, ctx) | ||
| from langflow.services.deps import session_scope | ||
|
|
||
| async with session_scope() as db: | ||
| sub_result = await db.exec(select(Subscription).where(Subscription.org_id == org_id)) | ||
| sub = sub_result.first() | ||
| if not sub: | ||
| return None | ||
|
|
||
| plan_result = await db.exec(select(Plan).where(Plan.id == sub.plan_id)) | ||
| plan = plan_result.first() | ||
| if not plan: | ||
| return None | ||
|
|
||
| return SubscriptionRead( | ||
| id=sub.id, | ||
| org_id=sub.org_id, | ||
| status=sub.status, | ||
| plan=PlanRead.model_validate(plan), | ||
| current_period_end=sub.current_period_end, | ||
| cancel_at_period_end=sub.cancel_at_period_end, | ||
| trial_end=sub.trial_end, | ||
| ) | ||
|
|
||
|
|
||
| class CheckoutRequest(PlanRead): | ||
| stripe_price_id: str | ||
| billing_cycle: str = "monthly" # "monthly" | "yearly" | ||
|
|
||
|
|
||
| @router.post("/orgs/{org_id}/billing/checkout") | ||
| async def create_checkout(org_id: UUID, request: Request, ctx: RequireAdmin): | ||
| """Create a Stripe Checkout Session and return the redirect URL.""" | ||
| assert_org_match(org_id, ctx) | ||
| settings = get_saas_settings() | ||
| if not settings.billing_enabled: | ||
| raise HTTPException(501, "Billing is not enabled on this instance.") | ||
|
|
||
| body = await request.json() | ||
| price_id: str = body.get("stripe_price_id", "") | ||
| if not price_id: | ||
| raise HTTPException(400, "stripe_price_id is required.") | ||
|
|
||
| from langflow.services.deps import session_scope | ||
|
|
||
| async with session_scope() as db: | ||
| org_result = await db.exec(select(Organization).where(Organization.id == org_id)) | ||
| org = org_result.first() | ||
| if not org: | ||
| raise HTTPException(404, "Organization not found.") | ||
|
|
||
| # Fetch owner email from Langflow user table. | ||
| from langflow.services.database.models.user.model import User | ||
|
|
||
| user_result = await db.exec(select(User).where(User.id == org.owner_id)) | ||
| owner = user_result.first() | ||
| owner_email = getattr(owner, "email", "") or f"{org.slug}@noemail.local" | ||
|
|
||
| url = await get_billing_service().create_checkout_session( | ||
| org_id=org_id, | ||
| org_name=org.name, | ||
| owner_email=owner_email, | ||
| stripe_price_id=price_id, | ||
| success_url=f"{settings.app_base_url}/settings/billing?success=1", | ||
| cancel_url=f"{settings.app_base_url}/settings/billing?canceled=1", | ||
| ) | ||
| return {"checkout_url": url} | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Usage summary | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @router.get("/orgs/{org_id}/usage", response_model=UsageSummary) | ||
| async def get_usage(org_id: UUID, ctx: CurrentOrgContext): | ||
| assert_org_match(org_id, ctx) | ||
| settings = get_saas_settings() | ||
| from langflow.services.deps import session_scope | ||
| from sqlalchemy import func | ||
|
|
||
| today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) | ||
|
|
||
| async with session_scope() as db: | ||
| # Get plan limits. | ||
| org_result = await db.exec(select(Organization).where(Organization.id == org_id)) | ||
| org = org_result.first() | ||
| plan: Plan | None = None | ||
| if org and org.plan_id: | ||
| plan_result = await db.exec(select(Plan).where(Plan.id == org.plan_id)) | ||
| plan = plan_result.first() | ||
|
|
||
| max_flows = plan.max_flows if plan else settings.default_max_flows | ||
| max_exec = plan.max_executions_per_day if plan else settings.default_max_executions_per_day | ||
| max_storage = plan.max_storage_mb if plan else settings.default_max_storage_mb | ||
|
|
||
| # Count executions today. | ||
| exec_result = await db.exec( | ||
| select(func.sum(UsageRecord.value)).where( | ||
| UsageRecord.org_id == org_id, | ||
| UsageRecord.metric == UsageMetric.FLOW_EXECUTION, | ||
| UsageRecord.recorded_at >= today_start, | ||
| ) | ||
| ) | ||
| execs_today = int(exec_result.first() or 0) | ||
|
|
||
| # Count API calls today. | ||
| api_result = await db.exec( | ||
| select(func.sum(UsageRecord.value)).where( | ||
| UsageRecord.org_id == org_id, | ||
| UsageRecord.metric == UsageMetric.API_CALL, | ||
| UsageRecord.recorded_at >= today_start, | ||
| ) | ||
| ) | ||
| api_calls_today = int(api_result.first() or 0) | ||
|
|
||
| # Storage (sum of all storage_bytes records for this org). | ||
| storage_result = await db.exec( | ||
| select(func.sum(UsageRecord.value)).where( | ||
| UsageRecord.org_id == org_id, UsageRecord.metric == UsageMetric.STORAGE_BYTES | ||
| ) | ||
| ) | ||
| storage_bytes = int(storage_result.first() or 0) | ||
|
|
||
| # Count flows from Langflow's flows table for org members. | ||
| # We aggregate flows belonging to all members of the org. | ||
| from langflow.services.database.models.flow.model import Flow | ||
|
|
||
| from langflow_saas.models import UserOrganization | ||
|
|
||
| member_result = await db.exec(select(UserOrganization.user_id).where(UserOrganization.org_id == org_id)) | ||
| member_ids = [r for r in member_result.all()] | ||
| flow_count = 0 | ||
| if member_ids: | ||
| flow_count_result = await db.exec( | ||
| select(func.count(Flow.id)).where(Flow.user_id.in_(member_ids)) # type: ignore[attr-defined] | ||
| ) | ||
| flow_count = int(flow_count_result.first() or 0) | ||
|
|
||
| return UsageSummary( | ||
| org_id=org_id, | ||
| executions_today=execs_today, | ||
| executions_limit=max_exec, | ||
| flows_count=flow_count, | ||
| flows_limit=max_flows, | ||
| storage_mb=round(storage_bytes / (1024 * 1024), 2), | ||
| storage_limit_mb=max_storage, | ||
| api_calls_today=api_calls_today, | ||
| plan_slug=plan.slug if plan else "free", | ||
| ) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Stripe Webhook (no auth — Stripe HMAC-verified) | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @router.post("/billing/webhook", status_code=status.HTTP_200_OK) | ||
| async def stripe_webhook(request: Request): | ||
| settings = get_saas_settings() | ||
| if not settings.billing_enabled: | ||
| raise HTTPException(501, "Billing not enabled.") | ||
|
|
||
| payload = await request.body() | ||
| sig_header = request.headers.get("stripe-signature", "") | ||
|
|
||
| try: | ||
| result = await get_billing_service().handle_webhook(payload=payload, sig_header=sig_header) | ||
| except Exception as exc: | ||
| raise HTTPException(400, f"Webhook processing failed: {exc}") from exc | ||
|
|
||
| return result | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Audit Log | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| @router.get("/audit") | ||
| async def get_audit_log( | ||
| ctx: RequireAdmin, | ||
| limit: int = 100, | ||
| offset: int = 0, | ||
| ): | ||
| """Paginated audit log for the current organization.""" | ||
| from langflow.services.deps import session_scope | ||
|
|
||
| async with session_scope() as db: | ||
| result = await db.exec( | ||
| select(AuditLog) | ||
| .where(AuditLog.org_id == ctx.org_id) | ||
| .order_by(AuditLog.created_at.desc()) # type: ignore[union-attr] | ||
| .offset(offset) | ||
| .limit(min(limit, 500)) | ||
| ) | ||
| entries = result.all() | ||
|
|
||
| return [ | ||
| { | ||
| "id": str(e.id), | ||
| "action": e.action, | ||
| "user_id": str(e.user_id) if e.user_id else None, | ||
| "resource_type": e.resource_type, | ||
| "resource_id": e.resource_id, | ||
| "metadata": e.log_metadata, | ||
| "ip_address": e.ip_address, | ||
| "created_at": e.created_at.isoformat(), | ||
| } | ||
| for e in entries | ||
| ] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stripe webhook lacks idempotency handling and leaks raw exception messages.
Stripe is documented to deliver events at-least-once and recommends idempotent processing keyed by
event.id. The current handler dispatches every payload toBillingService.handle_webhookwithout any seen-event check, which can cause duplicateSubscriptionupserts / audit entries. Additionally, surfacingf"Webhook processing failed: {exc}"in the response leaks internal error details to Stripe (and any attacker that can probe the endpoint). Log the exception server-side and return a generic 400.🛠 Suggested fix
Idempotency can be added inside
handle_webhookby recording processedevent.ids in a small dedicated table and short-circuiting on duplicates.🤖 Prompt for AI Agents