mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
468 lines
17 KiB
Python
468 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
|
|
|
|
from libs.datetime_utils import naive_utc_now
|
|
from models.dataset import Dataset
|
|
from models.model import App
|
|
from models.resource_access_token import (
|
|
ResourceAccessToken,
|
|
ResourceAccessTokenRelation,
|
|
ResourceAccessTokenResourceType,
|
|
)
|
|
|
|
TOKEN_PREFIX = "sk-"
|
|
MASK_PREFIX_LENGTH = 8
|
|
MASK_SUFFIX_LENGTH = 4
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResourceAccessTokenResource:
|
|
type: ResourceAccessTokenResourceType
|
|
id: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResourceAccessTokenRow:
|
|
token_id: str
|
|
relation_id: str
|
|
name: str
|
|
track_id: str
|
|
token: str | None
|
|
masked_token: str
|
|
resource_type: ResourceAccessTokenResourceType
|
|
resource_id: str
|
|
resource_name: str
|
|
created_at: datetime
|
|
last_used_at: datetime | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResourceAccessTokenCreateResult:
|
|
token_id: str
|
|
token: str
|
|
rows: list[ResourceAccessTokenRow]
|
|
|
|
|
|
class ResourceAccessTokenService:
|
|
@classmethod
|
|
def create(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
created_by: str,
|
|
name: str,
|
|
resources: list[ResourceAccessTokenResource],
|
|
session: Session,
|
|
) -> ResourceAccessTokenCreateResult:
|
|
name = cls._normalize_name(name)
|
|
if not resources:
|
|
raise BadRequest("At least one resource is required.")
|
|
|
|
token = ResourceAccessToken(
|
|
tenant_id=tenant_id,
|
|
name=name,
|
|
track_id=cls._generate_track_id(session),
|
|
token=cls._generate_token(session),
|
|
created_by=created_by,
|
|
)
|
|
session.add(token)
|
|
session.flush()
|
|
|
|
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
|
|
for resource in resources:
|
|
key = (resource.type, resource.id)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
|
|
relation = ResourceAccessTokenRelation(
|
|
token_id=token.id,
|
|
resource_type=resource.type,
|
|
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
|
|
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
|
|
)
|
|
session.add(relation)
|
|
|
|
session.commit()
|
|
rows = cls.list_rows(tenant_id=tenant_id, token_id=token.id, include_token=True, session=session)
|
|
return ResourceAccessTokenCreateResult(token_id=token.id, token=token.token, rows=rows)
|
|
|
|
@classmethod
|
|
def list_rows(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
session: Session,
|
|
page: int = 1,
|
|
limit: int = 20,
|
|
token_id: str | None = None,
|
|
include_token: bool = False,
|
|
) -> list[ResourceAccessTokenRow]:
|
|
stmt = (
|
|
select(ResourceAccessToken, ResourceAccessTokenRelation, App.name, Dataset.name)
|
|
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.token_id == ResourceAccessToken.id)
|
|
.outerjoin(App, App.id == ResourceAccessTokenRelation.app_id)
|
|
.outerjoin(Dataset, Dataset.id == ResourceAccessTokenRelation.dataset_id)
|
|
.where(ResourceAccessToken.tenant_id == tenant_id)
|
|
.order_by(ResourceAccessToken.created_at.desc(), ResourceAccessTokenRelation.created_at.desc())
|
|
)
|
|
if token_id:
|
|
stmt = stmt.where(ResourceAccessToken.id == token_id)
|
|
else:
|
|
token_ids = (
|
|
select(ResourceAccessToken.id)
|
|
.where(ResourceAccessToken.tenant_id == tenant_id)
|
|
.order_by(ResourceAccessToken.created_at.desc())
|
|
.offset(max(page - 1, 0) * limit)
|
|
.limit(limit)
|
|
)
|
|
stmt = stmt.where(ResourceAccessToken.id.in_(token_ids))
|
|
|
|
return [
|
|
cls._row_from_models(token, relation, app_name, dataset_name, include_token=include_token)
|
|
for token, relation, app_name, dataset_name in session.execute(stmt).all()
|
|
]
|
|
|
|
@classmethod
|
|
def count_rows(cls, *, tenant_id: str, session: Session) -> int:
|
|
return (
|
|
session.scalar(
|
|
select(func.count(ResourceAccessToken.id)).where(ResourceAccessToken.tenant_id == tenant_id)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
@classmethod
|
|
def update(
|
|
cls,
|
|
*,
|
|
tenant_id: str,
|
|
token_id: str,
|
|
name: str,
|
|
resources: list[ResourceAccessTokenResource] | None = None,
|
|
session: Session,
|
|
) -> list[ResourceAccessTokenRow]:
|
|
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
|
|
token.name = cls._normalize_name(name)
|
|
if resources is not None:
|
|
if not resources:
|
|
raise BadRequest("At least one resource is required.")
|
|
|
|
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
|
|
desired_resources: list[ResourceAccessTokenResource] = []
|
|
for resource in resources:
|
|
key = (resource.type, resource.id)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
|
|
desired_resources.append(resource)
|
|
|
|
existing_relations = session.scalars(
|
|
select(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
|
|
).all()
|
|
existing_by_key = {cls._relation_key(relation): relation for relation in existing_relations}
|
|
desired_keys = {(resource.type, resource.id) for resource in desired_resources}
|
|
|
|
for key, relation in existing_by_key.items():
|
|
if key not in desired_keys:
|
|
session.delete(relation)
|
|
|
|
for resource in desired_resources:
|
|
key = (resource.type, resource.id)
|
|
if key in existing_by_key:
|
|
continue
|
|
session.add(
|
|
ResourceAccessTokenRelation(
|
|
token_id=token.id,
|
|
resource_type=resource.type,
|
|
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
|
|
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
|
|
)
|
|
)
|
|
|
|
session.commit()
|
|
return cls.list_rows(tenant_id=tenant_id, token_id=token_id, session=session)
|
|
|
|
@classmethod
|
|
def delete_relation(cls, *, tenant_id: str, token_id: str, relation_id: str, session: Session) -> None:
|
|
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
|
|
relation = session.scalar(
|
|
select(ResourceAccessTokenRelation).where(
|
|
ResourceAccessTokenRelation.id == relation_id,
|
|
ResourceAccessTokenRelation.token_id == token.id,
|
|
)
|
|
)
|
|
if relation is None:
|
|
raise NotFound("Resource access token relation not found.")
|
|
|
|
session.execute(
|
|
delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
|
|
)
|
|
session.delete(token)
|
|
session.commit()
|
|
|
|
@classmethod
|
|
def delete_relations_for_app(cls, *, app_id: str, session: Session) -> None:
|
|
token_ids = cls._delete_relations(
|
|
resource_type=ResourceAccessTokenResourceType.APP,
|
|
resource_id_field=ResourceAccessTokenRelation.app_id,
|
|
resource_id=app_id,
|
|
session=session,
|
|
)
|
|
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
|
|
|
|
@classmethod
|
|
def delete_relations_for_dataset(cls, *, dataset_id: str, session: Session) -> None:
|
|
token_ids = cls._delete_relations(
|
|
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
|
|
resource_id_field=ResourceAccessTokenRelation.dataset_id,
|
|
resource_id=dataset_id,
|
|
session=session,
|
|
)
|
|
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
|
|
|
|
@classmethod
|
|
def validate_app_access(cls, *, token: str, app: App, session: Session) -> ResourceAccessToken:
|
|
access_token = cls._get_by_token(token=token, session=session)
|
|
relation = session.scalar(
|
|
select(ResourceAccessTokenRelation).where(
|
|
ResourceAccessTokenRelation.token_id == access_token.id,
|
|
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
|
|
ResourceAccessTokenRelation.app_id == app.id,
|
|
)
|
|
)
|
|
if relation is None:
|
|
raise Forbidden("Resource access token is not allowed to access this app.")
|
|
access_token.last_used_at = naive_utc_now()
|
|
session.commit()
|
|
return access_token
|
|
|
|
@classmethod
|
|
def resolve_app_for_service_api(
|
|
cls,
|
|
*,
|
|
token: str,
|
|
requested_app_id: str | None,
|
|
session: Session,
|
|
) -> App:
|
|
access_token = cls._get_by_token(token=token, session=session)
|
|
stmt = select(ResourceAccessTokenRelation).where(
|
|
ResourceAccessTokenRelation.token_id == access_token.id,
|
|
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
|
|
)
|
|
if requested_app_id:
|
|
stmt = stmt.where(ResourceAccessTokenRelation.app_id == requested_app_id)
|
|
|
|
relations = session.scalars(stmt).all()
|
|
if not relations:
|
|
raise Forbidden("Resource access token is not allowed to access this app.")
|
|
if len(relations) > 1:
|
|
raise BadRequest("App ID is required when a resource access token is bound to multiple apps.")
|
|
|
|
app_id = relations[0].app_id
|
|
if app_id is None:
|
|
raise Forbidden("Resource access token is not allowed to access this app.")
|
|
app = session.get(App, app_id)
|
|
if app is None:
|
|
raise Forbidden("The app no longer exists.")
|
|
access_token.last_used_at = naive_utc_now()
|
|
session.commit()
|
|
return app
|
|
|
|
@classmethod
|
|
def validate_dataset_access(cls, *, token: str, dataset_id: str, session: Session) -> ResourceAccessToken:
|
|
access_token = cls._get_by_token(token=token, session=session)
|
|
relation = session.scalar(
|
|
select(ResourceAccessTokenRelation).where(
|
|
ResourceAccessTokenRelation.token_id == access_token.id,
|
|
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
|
|
ResourceAccessTokenRelation.dataset_id == dataset_id,
|
|
)
|
|
)
|
|
if relation is None:
|
|
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
|
|
access_token.last_used_at = naive_utc_now()
|
|
session.commit()
|
|
return access_token
|
|
|
|
@classmethod
|
|
def resolve_tenant_for_dataset_service_api(
|
|
cls,
|
|
*,
|
|
token: str,
|
|
dataset_id: str | None,
|
|
session: Session,
|
|
) -> str:
|
|
access_token = cls._get_by_token(token=token, session=session)
|
|
stmt = select(ResourceAccessTokenRelation).where(
|
|
ResourceAccessTokenRelation.token_id == access_token.id,
|
|
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
|
|
)
|
|
if dataset_id:
|
|
stmt = stmt.where(ResourceAccessTokenRelation.dataset_id == dataset_id)
|
|
|
|
relation = session.scalar(stmt.limit(1))
|
|
if relation is None:
|
|
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
|
|
access_token.last_used_at = naive_utc_now()
|
|
session.commit()
|
|
return access_token.tenant_id
|
|
|
|
@staticmethod
|
|
def is_resource_access_token(token: str) -> bool:
|
|
return token.startswith(TOKEN_PREFIX)
|
|
|
|
@staticmethod
|
|
def mask_token(token: str) -> str:
|
|
if len(token) <= MASK_PREFIX_LENGTH + MASK_SUFFIX_LENGTH:
|
|
return token
|
|
return f"{token[:MASK_PREFIX_LENGTH]}...{token[-MASK_SUFFIX_LENGTH:]}"
|
|
|
|
@classmethod
|
|
def _get_by_token(cls, *, token: str, session: Session) -> ResourceAccessToken:
|
|
access_token = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
|
|
if access_token is None:
|
|
raise Unauthorized("Resource access token is invalid.")
|
|
return access_token
|
|
|
|
@classmethod
|
|
def _get_token(cls, *, tenant_id: str, token_id: str, session: Session) -> ResourceAccessToken:
|
|
token = session.scalar(
|
|
select(ResourceAccessToken).where(
|
|
ResourceAccessToken.id == token_id,
|
|
ResourceAccessToken.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
if token is None:
|
|
raise NotFound("Resource access token not found.")
|
|
return token
|
|
|
|
@staticmethod
|
|
def _normalize_name(name: str) -> str:
|
|
normalized = name.strip()
|
|
if not normalized:
|
|
raise BadRequest("Name is required.")
|
|
return normalized
|
|
|
|
@classmethod
|
|
def _generate_token(cls, session: Session) -> str:
|
|
while True:
|
|
value = f"{TOKEN_PREFIX}{uuid4()}"
|
|
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.token == value)):
|
|
return value
|
|
|
|
@classmethod
|
|
def _generate_track_id(cls, session: Session) -> str:
|
|
while True:
|
|
value = uuid4().hex
|
|
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.track_id == value)):
|
|
return value
|
|
|
|
@staticmethod
|
|
def _ensure_resource_available(
|
|
*,
|
|
tenant_id: str,
|
|
resource: ResourceAccessTokenResource,
|
|
session: Session,
|
|
) -> None:
|
|
if resource.type == ResourceAccessTokenResourceType.APP:
|
|
app = session.scalar(select(App).where(App.id == resource.id, App.tenant_id == tenant_id))
|
|
if app is None:
|
|
raise NotFound("App not found.")
|
|
if app.status != "normal" or not app.enable_api:
|
|
raise Forbidden("App API access is not enabled.")
|
|
return
|
|
|
|
dataset = session.scalar(select(Dataset).where(Dataset.id == resource.id, Dataset.tenant_id == tenant_id))
|
|
if dataset is None:
|
|
raise NotFound("Knowledge base not found.")
|
|
if not dataset.enable_api:
|
|
raise Forbidden("Knowledge base API access is not enabled.")
|
|
|
|
@staticmethod
|
|
def _delete_relations(
|
|
*,
|
|
resource_type: ResourceAccessTokenResourceType,
|
|
resource_id_field,
|
|
resource_id: str,
|
|
session: Session,
|
|
) -> list[str]:
|
|
token_ids = session.scalars(
|
|
select(ResourceAccessTokenRelation.token_id).where(
|
|
ResourceAccessTokenRelation.resource_type == resource_type,
|
|
resource_id_field == resource_id,
|
|
)
|
|
).all()
|
|
if token_ids:
|
|
session.execute(
|
|
delete(ResourceAccessTokenRelation).where(
|
|
ResourceAccessTokenRelation.resource_type == resource_type,
|
|
resource_id_field == resource_id,
|
|
)
|
|
)
|
|
return list(token_ids)
|
|
|
|
@staticmethod
|
|
def _delete_orphan_tokens(*, token_ids: list[str], session: Session) -> None:
|
|
for token_id in set(token_ids):
|
|
remaining_count = (
|
|
session.scalar(
|
|
select(func.count(ResourceAccessTokenRelation.id)).where(
|
|
ResourceAccessTokenRelation.token_id == token_id,
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
if remaining_count == 0:
|
|
session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
|
|
|
|
@staticmethod
|
|
def _relation_key(
|
|
relation: ResourceAccessTokenRelation,
|
|
) -> tuple[ResourceAccessTokenResourceType, str]:
|
|
resource_id = (
|
|
relation.app_id
|
|
if relation.resource_type == ResourceAccessTokenResourceType.APP
|
|
else relation.dataset_id
|
|
)
|
|
assert resource_id is not None
|
|
return relation.resource_type, resource_id
|
|
|
|
@classmethod
|
|
def _row_from_models(
|
|
cls,
|
|
token: ResourceAccessToken,
|
|
relation: ResourceAccessTokenRelation,
|
|
app_name: str | None,
|
|
dataset_name: str | None,
|
|
*,
|
|
include_token: bool,
|
|
) -> ResourceAccessTokenRow:
|
|
resource_id = (
|
|
relation.app_id
|
|
if relation.resource_type == ResourceAccessTokenResourceType.APP
|
|
else relation.dataset_id
|
|
)
|
|
assert resource_id is not None
|
|
return ResourceAccessTokenRow(
|
|
token_id=token.id,
|
|
relation_id=relation.id,
|
|
name=token.name,
|
|
track_id=token.track_id,
|
|
token=token.token if include_token else None,
|
|
masked_token=cls.mask_token(token.token),
|
|
resource_type=relation.resource_type,
|
|
resource_id=resource_id,
|
|
resource_name=app_name or dataset_name or "",
|
|
created_at=token.created_at,
|
|
last_used_at=token.last_used_at,
|
|
)
|