mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
The legacy dataset console rate limits every mutating, download, and retrieval route and the dataset service API limits every request, while KnowledgeFS only covered uploads and query admission. Apply the knowledge rate limit to all space-scoped POST/PUT/PATCH/DELETE console routes, the document downloads, and research/query entrypoints (workspace-level source previews, staged-upload discards, the deprecated buffered query route, and the internal stream transport stay exempt). Extract the service API check into `check_knowledge_rate_limit` and call it from the KnowledgeFS profile helper so all service routes share the legacy per-workspace limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
448 lines
18 KiB
Python
448 lines
18 KiB
Python
import inspect
|
|
import logging
|
|
import time
|
|
from collections.abc import Callable
|
|
from enum import StrEnum, auto
|
|
from functools import wraps
|
|
from typing import Protocol, cast, overload
|
|
|
|
from flask import current_app, request
|
|
from flask_login import user_logged_in
|
|
from flask_restx import Resource
|
|
from flask_restx.utils import merge
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized
|
|
|
|
from configs import dify_config
|
|
from controllers.service_api.schema import (
|
|
USER_FETCH_FROM_ATTR,
|
|
USER_FORM_PARAM,
|
|
USER_QUERY_PARAM,
|
|
USER_REQUIRED_ATTR,
|
|
)
|
|
from enums import CloudPlan, DeploymentEdition
|
|
from extensions.ext_application_services import application_services
|
|
from extensions.ext_database import db
|
|
from extensions.ext_redis import redis_client
|
|
from libs.login import current_user
|
|
from models import Account, Tenant, TenantAccountJoin, TenantStatus
|
|
from models.dataset import Dataset, RateLimitLog
|
|
from models.model import ApiToken, App
|
|
from services import dataset_api_key_service
|
|
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
|
|
from services.end_user_service import EndUserService
|
|
from services.feature_service import FeatureService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _RestxDocumentedView(Protocol):
|
|
"""Callable view object carrying Flask-RESTX documentation metadata."""
|
|
|
|
__apidoc__: dict[str, object]
|
|
|
|
|
|
class WhereisUserArg(StrEnum):
|
|
"""
|
|
Enum for whereis_user_arg.
|
|
"""
|
|
|
|
QUERY = auto()
|
|
JSON = auto()
|
|
FORM = auto()
|
|
|
|
|
|
class FetchUserArg(BaseModel):
|
|
fetch_from: WhereisUserArg
|
|
required: bool = False
|
|
|
|
|
|
APP_TOKEN_FORBIDDEN_RESPONSE = {
|
|
403: "Forbidden - token scope, app, dataset, or workspace access denied",
|
|
}
|
|
|
|
DATASET_TOKEN_AUTH_RESPONSES = {
|
|
401: "Unauthorized - invalid API token",
|
|
403: "Forbidden - dataset API access or workspace access denied",
|
|
}
|
|
VECTOR_SPACE_UNAVAILABLE_RESPONSE = {
|
|
503: (
|
|
"`service_unavailable` : Vector space usage could not be verified. Returned on the Dify Cloud Sandbox "
|
|
"plan only; retry the request later."
|
|
),
|
|
}
|
|
|
|
|
|
def _document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None:
|
|
doc: dict[str, object] = {"responses": APP_TOKEN_FORBIDDEN_RESPONSE}
|
|
if fetch_user_arg is not None:
|
|
setattr(view_func, USER_FETCH_FROM_ATTR, fetch_user_arg.fetch_from.name)
|
|
setattr(view_func, USER_REQUIRED_ATTR, fetch_user_arg.required)
|
|
match fetch_user_arg.fetch_from:
|
|
case WhereisUserArg.QUERY:
|
|
doc["params"] = {"user": {**USER_QUERY_PARAM, "required": fetch_user_arg.required}}
|
|
case WhereisUserArg.FORM:
|
|
doc["params"] = {"user": {**USER_FORM_PARAM, "required": fetch_user_arg.required}}
|
|
case WhereisUserArg.JSON:
|
|
pass
|
|
|
|
cast(_RestxDocumentedView, view_func).__apidoc__ = cast(
|
|
dict[str, object],
|
|
merge(getattr(view_func, "__apidoc__", {}), doc),
|
|
)
|
|
|
|
|
|
@overload
|
|
def validate_app_token[**P, R](view: Callable[P, R]) -> Callable[P, R]: ...
|
|
|
|
|
|
@overload
|
|
def validate_app_token[**P, R](
|
|
view: None = None, *, fetch_user_arg: FetchUserArg | None = None
|
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
|
|
|
|
def validate_app_token[**P, R](
|
|
view: Callable[P, R] | None = None, *, fetch_user_arg: FetchUserArg | None = None
|
|
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
|
|
@wraps(view_func)
|
|
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
api_token = validate_and_get_api_token("app")
|
|
|
|
app_model = db.session.get(App, api_token.app_id)
|
|
if not app_model:
|
|
raise Forbidden("The app no longer exists.")
|
|
|
|
if app_model.status != "normal":
|
|
raise Forbidden("The app's status is abnormal.")
|
|
|
|
if not app_model.enable_api:
|
|
raise Forbidden("The app's API service has been disabled.")
|
|
|
|
tenant = db.session.get(Tenant, app_model.tenant_id)
|
|
if tenant is None:
|
|
raise ValueError("Tenant does not exist.")
|
|
if tenant.status == TenantStatus.ARCHIVE:
|
|
raise Forbidden("The workspace's status is archived.")
|
|
|
|
kwargs["app_model"] = app_model
|
|
|
|
# If caller needs end-user context, attach EndUser to current_user
|
|
if fetch_user_arg:
|
|
user_id = None
|
|
match fetch_user_arg.fetch_from:
|
|
case WhereisUserArg.QUERY:
|
|
user_id = request.args.get("user")
|
|
case WhereisUserArg.JSON:
|
|
user_id = request.get_json().get("user")
|
|
case WhereisUserArg.FORM:
|
|
user_id = request.form.get("user")
|
|
|
|
if not user_id and fetch_user_arg.required:
|
|
raise ValueError("Arg user must be provided.")
|
|
|
|
if user_id:
|
|
user_id = str(user_id)
|
|
|
|
end_user = EndUserService.get_or_create_end_user(app_model, user_id)
|
|
kwargs["end_user"] = end_user
|
|
|
|
# Set EndUser as current logged-in user for flask_login.current_user
|
|
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
|
|
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore
|
|
else:
|
|
# For service API without end-user context, ensure an Account is logged in
|
|
# so services relying on current_account_with_tenant() work correctly.
|
|
tenant_owner_info = db.session.execute(
|
|
select(Tenant, Account)
|
|
.join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id)
|
|
.join(Account, TenantAccountJoin.account_id == Account.id)
|
|
.where(
|
|
Tenant.id == app_model.tenant_id,
|
|
TenantAccountJoin.role == "owner",
|
|
Tenant.status == TenantStatus.NORMAL,
|
|
)
|
|
).one_or_none()
|
|
|
|
if tenant_owner_info:
|
|
tenant_model, account = tenant_owner_info
|
|
account.set_current_tenant_with_session(tenant_model, session=db.session())
|
|
current_app.login_manager._update_request_context_with_user(account) # type: ignore
|
|
user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore
|
|
else:
|
|
raise Unauthorized("Tenant owner account not found or tenant is not active.")
|
|
|
|
return view_func(*args, **kwargs)
|
|
|
|
_document_app_token_contract(decorated_view, fetch_user_arg)
|
|
return decorated_view
|
|
|
|
if view is None:
|
|
return decorator
|
|
else:
|
|
return decorator(view)
|
|
|
|
|
|
def cloud_edition_billing_resource_check[**P, R](
|
|
resource: str,
|
|
api_token_type: str,
|
|
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
def interceptor(view: Callable[P, R]):
|
|
@wraps(view)
|
|
def decorated(*args: P.args, **kwargs: P.kwargs):
|
|
api_token = validate_and_get_api_token(api_token_type)
|
|
if resource == "vector_space":
|
|
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
|
|
return view(*args, **kwargs)
|
|
|
|
vector_space = application_services().feature_queries.get_workspace_vector_space(api_token.tenant_id)
|
|
if vector_space.usage_unknown:
|
|
features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True)
|
|
if features.billing.enabled and features.billing.subscription.plan == CloudPlan.SANDBOX:
|
|
raise ServiceUnavailable(
|
|
"Unable to verify vector space usage right now. Please try again later."
|
|
)
|
|
if 0 < vector_space.limit <= vector_space.size:
|
|
raise Forbidden("The capacity of the vector space has reached the limit of your subscription.")
|
|
return view(*args, **kwargs)
|
|
|
|
features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True)
|
|
|
|
if features.billing.enabled:
|
|
members = features.members
|
|
apps = features.apps
|
|
documents_upload_quota = features.documents_upload_quota
|
|
|
|
if resource == "members" and 0 < members.limit <= members.size:
|
|
raise Forbidden("The number of members has reached the limit of your subscription.")
|
|
elif resource == "apps" and 0 < apps.limit <= apps.size:
|
|
raise Forbidden("The number of apps has reached the limit of your subscription.")
|
|
elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size:
|
|
raise Forbidden("The number of documents has reached the limit of your subscription.")
|
|
else:
|
|
return view(*args, **kwargs)
|
|
|
|
return view(*args, **kwargs)
|
|
|
|
if resource == "vector_space":
|
|
cast(_RestxDocumentedView, decorated).__apidoc__ = cast(
|
|
dict[str, object],
|
|
merge(decorated.__dict__.get("__apidoc__", {}), {"responses": VECTOR_SPACE_UNAVAILABLE_RESPONSE}),
|
|
)
|
|
return decorated
|
|
|
|
return interceptor
|
|
|
|
|
|
def cloud_edition_billing_knowledge_limit_check[**P, R](
|
|
resource: str,
|
|
api_token_type: str,
|
|
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
def interceptor(view: Callable[P, R]):
|
|
@wraps(view)
|
|
def decorated(*args: P.args, **kwargs: P.kwargs):
|
|
api_token = validate_and_get_api_token(api_token_type)
|
|
features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True)
|
|
if features.billing.enabled:
|
|
if resource == "add_segment":
|
|
if features.billing.subscription.plan == CloudPlan.SANDBOX:
|
|
raise Forbidden(
|
|
"To unlock this feature and elevate your Dify experience, please upgrade to a paid plan."
|
|
)
|
|
else:
|
|
return view(*args, **kwargs)
|
|
|
|
return view(*args, **kwargs)
|
|
|
|
return decorated
|
|
|
|
return interceptor
|
|
|
|
|
|
def check_knowledge_rate_limit(api_token: ApiToken) -> None:
|
|
"""Enforce the cloud knowledge base request rate limit for a dataset API key's workspace.
|
|
|
|
Shared by the ``cloud_edition_billing_rate_limit_check`` decorator and the KnowledgeFS
|
|
service routes, which authenticate inside their own profile helper rather than through
|
|
a decorator. No-op unless the workspace has a knowledge rate limit (cloud billing).
|
|
"""
|
|
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(api_token.tenant_id)
|
|
if not knowledge_rate_limit.enabled:
|
|
return
|
|
|
|
current_time = int(time.time() * 1000)
|
|
key = f"rate_limit_{api_token.tenant_id}"
|
|
|
|
redis_client.zadd(key, {current_time: current_time})
|
|
|
|
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
|
|
|
request_count = redis_client.zcard(key)
|
|
|
|
if request_count > knowledge_rate_limit.limit:
|
|
# add ratelimit record
|
|
rate_limit_log = RateLimitLog(
|
|
tenant_id=api_token.tenant_id,
|
|
subscription_plan=knowledge_rate_limit.subscription_plan,
|
|
operation="knowledge",
|
|
)
|
|
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
|
session.add(rate_limit_log)
|
|
raise Forbidden("Sorry, you have reached the knowledge base request rate limit of your subscription.")
|
|
|
|
|
|
def cloud_edition_billing_rate_limit_check[**P, R](
|
|
resource: str,
|
|
api_token_type: str,
|
|
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
def interceptor(view: Callable[P, R]):
|
|
@wraps(view)
|
|
def decorated(*args: P.args, **kwargs: P.kwargs):
|
|
api_token = validate_and_get_api_token(api_token_type)
|
|
|
|
if resource == "knowledge":
|
|
check_knowledge_rate_limit(api_token)
|
|
return view(*args, **kwargs)
|
|
|
|
return decorated
|
|
|
|
return interceptor
|
|
|
|
|
|
def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
|
positional_parameters = [
|
|
parameter
|
|
for parameter in inspect.signature(view).parameters.values()
|
|
if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
]
|
|
expects_bound_instance = bool(positional_parameters and positional_parameters[0].name in {"self", "cls"})
|
|
|
|
@wraps(view)
|
|
def decorated(*args: object, **kwargs: object) -> R:
|
|
api_token = validate_and_get_api_token("dataset")
|
|
|
|
# Flask may pass URL path parameters positionally, so inspect both kwargs and args.
|
|
dataset_id = kwargs.get("dataset_id")
|
|
|
|
if not dataset_id and args:
|
|
potential_id = args[0]
|
|
try:
|
|
str_id = str(potential_id)
|
|
if len(str_id) == 36 and str_id.count("-") == 4:
|
|
dataset_id = str_id
|
|
except Exception:
|
|
logger.exception("Failed to parse dataset_id from positional args")
|
|
|
|
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
|
|
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
|
|
# N rows -> the key is limited to exactly those resources
|
|
# Only legacy-dataset bindings count here: a key bound solely to KnowledgeFS spaces
|
|
# is restricted but has no reachable legacy dataset, so it is rejected. A bound key
|
|
# may only call endpoints carrying one of its dataset ids; endpoints without a
|
|
# dataset id (e.g. list/create datasets) are rejected. The scope is queried per
|
|
# request (not cached) so changes take effect immediately.
|
|
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
|
|
# typed helper accepts it (runtime proxies every Session method through unchanged).
|
|
scope = dataset_api_key_service.get_key_scope(cast(Session, db.session), api_token.id)
|
|
if not scope.allows_dataset(str(dataset_id) if dataset_id else None):
|
|
raise Forbidden("The API key is not authorized to access this knowledge base.")
|
|
|
|
if dataset_id:
|
|
dataset_id = str(dataset_id)
|
|
dataset = db.session.scalar(
|
|
select(Dataset)
|
|
.where(
|
|
Dataset.id == dataset_id,
|
|
Dataset.tenant_id == api_token.tenant_id,
|
|
)
|
|
.limit(1)
|
|
)
|
|
if not dataset:
|
|
raise NotFound("Dataset not found.")
|
|
if not dataset.enable_api:
|
|
raise Forbidden("Dataset api access is not enabled.")
|
|
|
|
tenant_account_join = db.session.execute(
|
|
select(Tenant, TenantAccountJoin).where(
|
|
Tenant.id == api_token.tenant_id,
|
|
TenantAccountJoin.tenant_id == Tenant.id,
|
|
TenantAccountJoin.role.in_(["owner"]),
|
|
Tenant.status == TenantStatus.NORMAL,
|
|
)
|
|
).one_or_none() # TODO: only owner information is required, so only one is returned.
|
|
if tenant_account_join:
|
|
tenant, ta = tenant_account_join
|
|
account = db.session.get(Account, ta.account_id)
|
|
# Login admin
|
|
if account:
|
|
account.set_current_tenant_with_session(tenant, session=db.session())
|
|
current_app.login_manager._update_request_context_with_user(account) # type: ignore
|
|
user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore
|
|
else:
|
|
raise Unauthorized("Tenant owner account does not exist.")
|
|
else:
|
|
raise Unauthorized("Tenant does not exist.")
|
|
|
|
if expects_bound_instance:
|
|
if not args:
|
|
raise TypeError("validate_dataset_token expected a bound resource instance.")
|
|
return view(args[0], api_token.tenant_id, *args[1:], **kwargs)
|
|
|
|
return view(api_token.tenant_id, *args, **kwargs)
|
|
|
|
return decorated
|
|
|
|
|
|
def validate_and_get_api_token(scope: str | None = None):
|
|
"""
|
|
Validate and get API token with Redis caching.
|
|
|
|
This function uses a two-tier approach:
|
|
1. First checks Redis cache for the token
|
|
2. If not cached, queries database and caches the result
|
|
|
|
The last_used_at field is updated asynchronously via Celery task
|
|
to avoid blocking the request.
|
|
"""
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header is None or " " not in auth_header:
|
|
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
|
|
|
|
auth_scheme, auth_token = auth_header.split(None, 1)
|
|
auth_scheme = auth_scheme.lower()
|
|
|
|
if auth_scheme != "bearer":
|
|
raise Unauthorized("Authorization scheme must be 'Bearer'")
|
|
|
|
# Try to get token from cache first
|
|
# Returns a CachedApiToken (plain Python object), not a SQLAlchemy model
|
|
cached_token = ApiTokenCache.get(auth_token, scope)
|
|
if cached_token is not None:
|
|
logger.debug("Token validation served from cache for scope: %s", scope)
|
|
# Record usage in Redis for later batch update (no Celery task per request)
|
|
record_token_usage(auth_token, scope)
|
|
return cast(ApiToken, cached_token)
|
|
|
|
# Cache miss - use Redis lock for single-flight mode
|
|
# This ensures only one request queries DB for the same token concurrently
|
|
return fetch_token_with_single_flight(auth_token, scope)
|
|
|
|
|
|
class DatasetApiResource(Resource):
|
|
__apidoc__ = {"responses": DATASET_TOKEN_AUTH_RESPONSES}
|
|
|
|
method_decorators = [validate_dataset_token]
|
|
|
|
def get_dataset(self, dataset_id: str, tenant_id: str) -> Dataset:
|
|
dataset = db.session.scalar(
|
|
select(Dataset).where(Dataset.id == dataset_id, Dataset.tenant_id == tenant_id).limit(1)
|
|
)
|
|
|
|
if not dataset:
|
|
raise NotFound("Dataset not found.")
|
|
|
|
return dataset
|