Skip to content

Commit c0dbb26

Browse files
fateleiautofix-ci[bot]wylswzWTW0313
authored
feat: access token feature (#42293)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yunlu.wen <yunlu.wen@dify.ai> Co-authored-by: twwu <twwu@dify.ai>
1 parent 8c72fd0 commit c0dbb26

122 files changed

Lines changed: 6170 additions & 99 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.

‎api/constants/oauth_bearer.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class SubjectType(StrEnum):
2828
# not is keyed by its external issuer instead.
2929
bound_to_account: bool
3030

31+
RESOURCE_ACCESS = ("resource_access", frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN}), False)
3132
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
3233
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
3334

@@ -47,6 +48,7 @@ class TokenType(StrEnum):
4748
prefix: str
4849
subject: SubjectType
4950

51+
RESOURCE_ACCESS = ("resource_access", "sk-", SubjectType.RESOURCE_ACCESS)
5052
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
5153
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)
5254

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Resource access token vocabulary shared by persistence and application services."""
2+
3+
from enum import StrEnum
4+
5+
TOKEN_PREFIX = "sk-"
6+
7+
8+
class ResourceAccessTokenResourceType(StrEnum):
9+
APP = "app"
10+
KNOWLEDGE = "knowledge"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Translate machine credential application failures at HTTP transport boundaries."""
2+
3+
from collections.abc import Generator
4+
from contextlib import contextmanager
5+
6+
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
7+
8+
from services.auth.resource_access_token_contracts import (
9+
ResourceAccessTokenForbiddenError,
10+
ResourceAccessTokenInputError,
11+
ResourceAccessTokenInvalidError,
12+
ResourceAccessTokenNotFoundError,
13+
)
14+
15+
16+
@contextmanager
17+
def resource_access_token_errors() -> Generator[None, None, None]:
18+
try:
19+
yield
20+
except ResourceAccessTokenInputError as error:
21+
raise BadRequest(str(error)) from error
22+
except ResourceAccessTokenForbiddenError as error:
23+
raise Forbidden(str(error)) from error
24+
except ResourceAccessTokenNotFoundError as error:
25+
raise NotFound(str(error)) from error
26+
except ResourceAccessTokenInvalidError as error:
27+
raise Unauthorized(str(error)) from error

‎api/controllers/console/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError)
6565
knowledge_fs_proxy,
6666
notification,
6767
onboarding,
68+
resource_access_token,
6869
setup,
6970
spec,
7071
system,
@@ -243,6 +244,7 @@ def _handle_active_workspace_required_error(error: ActiveWorkspaceRequiredError)
243244
"rag_pipeline_workflow",
244245
"rbac",
245246
"recommended_app",
247+
"resource_access_token",
246248
"saved_message",
247249
"setup",
248250
"site",

‎api/controllers/console/app/app.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class AppListBaseQuery(BaseModel):
109109
description="Sort apps by last modified, recently created, or earliest created",
110110
)
111111
name: str | None = Field(default=None, description="Filter by app name")
112+
openapi_visible: bool = Field(default=False, description="Only return apps with API access enabled")
112113
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
113114
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
114115
is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime
4+
from uuid import UUID
5+
6+
from flask_restx import Resource
7+
from pydantic import Field, field_validator
8+
9+
from constants.resource_access_token import ResourceAccessTokenResourceType
10+
from controllers.common.resource_access_token_errors import resource_access_token_errors
11+
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
12+
from controllers.console import console_ns
13+
from controllers.console.flask_admission import console_account_admission
14+
from controllers.console.wraps import model_validate
15+
from extensions.ext_application_services import application_services
16+
from fields.base import ResponseModel
17+
from libs.helper import dump_response, to_timestamp
18+
from machinery.context import RequestContext
19+
from models.account import TenantAccountRole
20+
from services.auth.resource_access_token_contracts import (
21+
ResourceAccessTokenResource,
22+
ResourceAccessTokenRow,
23+
)
24+
25+
26+
class ResourceAccessTokenResourcePayload(ResponseModel):
27+
type: ResourceAccessTokenResourceType
28+
id: str
29+
30+
31+
class ResourceAccessTokenCreatePayload(ResponseModel):
32+
name: str = Field(min_length=1)
33+
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
34+
35+
36+
class ResourceAccessTokenUpdatePayload(ResponseModel):
37+
name: str = Field(min_length=1)
38+
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
39+
40+
41+
class ResourceAccessTokenListQuery(ResponseModel):
42+
page: int = Field(default=1, ge=1)
43+
limit: int = Field(default=20, ge=1, le=100)
44+
keyword: str | None = Field(default=None, min_length=1, max_length=100)
45+
46+
47+
class ResourceAccessTokenRowResponse(ResponseModel):
48+
token_id: str
49+
relation_id: str
50+
name: str
51+
track_id: str
52+
token: str | None = None
53+
masked_token: str
54+
resource_type: ResourceAccessTokenResourceType
55+
resource_id: str
56+
resource_name: str
57+
created_at: int
58+
last_used_at: int | None = None
59+
60+
@field_validator("created_at", "last_used_at", mode="before")
61+
@classmethod
62+
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
63+
return to_timestamp(value)
64+
65+
66+
class ResourceAccessTokenListResponse(ResponseModel):
67+
data: list[ResourceAccessTokenRowResponse]
68+
page: int
69+
limit: int
70+
total: int
71+
has_more: bool
72+
73+
74+
class ResourceAccessTokenCreateResponse(ResponseModel):
75+
token: str
76+
data: list[ResourceAccessTokenRowResponse]
77+
78+
79+
register_schema_models(
80+
console_ns,
81+
ResourceAccessTokenResourcePayload,
82+
ResourceAccessTokenCreatePayload,
83+
ResourceAccessTokenUpdatePayload,
84+
ResourceAccessTokenListQuery,
85+
)
86+
register_response_schema_models(
87+
console_ns,
88+
ResourceAccessTokenRowResponse,
89+
ResourceAccessTokenListResponse,
90+
ResourceAccessTokenCreateResponse,
91+
)
92+
93+
94+
_OWNER_ROLES = frozenset({TenantAccountRole.OWNER})
95+
96+
97+
def _dump_rows(rows: tuple[ResourceAccessTokenRow, ...]) -> list[dict[str, object]]:
98+
return [
99+
dump_response(
100+
ResourceAccessTokenRowResponse,
101+
ResourceAccessTokenRowResponse.model_validate(row, from_attributes=True),
102+
)
103+
for row in rows
104+
]
105+
106+
107+
@console_ns.route("/resource-access-tokens")
108+
class ResourceAccessTokenListApi(Resource):
109+
@console_ns.doc("list_resource_access_tokens")
110+
@console_ns.doc(params=query_params_from_model(ResourceAccessTokenListQuery))
111+
@console_ns.response(200, "Resource access tokens", console_ns.models[ResourceAccessTokenListResponse.__name__])
112+
@console_account_admission(allowed_roles=_OWNER_ROLES)
113+
@model_validate(ResourceAccessTokenListQuery)
114+
def get(
115+
self,
116+
query: ResourceAccessTokenListQuery,
117+
context: RequestContext,
118+
) -> dict[str, object]:
119+
with resource_access_token_errors():
120+
rows = application_services().resource_access_tokens.list_rows(
121+
context,
122+
page=query.page,
123+
limit=query.limit,
124+
keyword=query.keyword,
125+
)
126+
total = application_services().resource_access_tokens.count_rows(context, keyword=query.keyword)
127+
return dump_response(
128+
ResourceAccessTokenListResponse,
129+
{
130+
"data": _dump_rows(rows),
131+
"page": query.page,
132+
"limit": query.limit,
133+
"total": total,
134+
"has_more": query.page * query.limit < total,
135+
},
136+
)
137+
138+
@console_ns.doc("create_resource_access_token")
139+
@console_ns.expect(console_ns.models[ResourceAccessTokenCreatePayload.__name__])
140+
@console_ns.response(
141+
201,
142+
"Resource access token created",
143+
console_ns.models[ResourceAccessTokenCreateResponse.__name__],
144+
)
145+
@console_account_admission(allowed_roles=_OWNER_ROLES)
146+
@model_validate(ResourceAccessTokenCreatePayload)
147+
def post(
148+
self,
149+
payload: ResourceAccessTokenCreatePayload,
150+
context: RequestContext,
151+
) -> tuple[dict[str, object], int]:
152+
with resource_access_token_errors():
153+
result = application_services().resource_access_tokens.create(
154+
context,
155+
name=payload.name,
156+
resources=tuple(
157+
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
158+
),
159+
)
160+
return dump_response(
161+
ResourceAccessTokenCreateResponse,
162+
{"token": result.token, "data": _dump_rows(result.rows)},
163+
), 201
164+
165+
166+
@console_ns.route("/resource-access-tokens/<uuid:token_id>")
167+
class ResourceAccessTokenApi(Resource):
168+
@console_ns.doc("update_resource_access_token")
169+
@console_ns.expect(console_ns.models[ResourceAccessTokenUpdatePayload.__name__])
170+
@console_ns.response(
171+
200,
172+
"Resource access token updated",
173+
console_ns.models[ResourceAccessTokenListResponse.__name__],
174+
)
175+
@console_account_admission(allowed_roles=_OWNER_ROLES)
176+
@model_validate(ResourceAccessTokenUpdatePayload)
177+
def patch(
178+
self,
179+
payload: ResourceAccessTokenUpdatePayload,
180+
context: RequestContext,
181+
token_id: UUID,
182+
) -> dict[str, object]:
183+
with resource_access_token_errors():
184+
rows = application_services().resource_access_tokens.update(
185+
context,
186+
token_id=str(token_id),
187+
name=payload.name,
188+
resources=tuple(
189+
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
190+
),
191+
)
192+
return dump_response(
193+
ResourceAccessTokenListResponse,
194+
{
195+
"data": _dump_rows(rows),
196+
"page": 1,
197+
"limit": len(rows),
198+
"total": len(rows),
199+
"has_more": False,
200+
},
201+
)
202+
203+
204+
@console_ns.route("/resource-access-tokens/<uuid:token_id>/relations/<uuid:relation_id>")
205+
class ResourceAccessTokenRelationApi(Resource):
206+
@console_ns.doc("delete_resource_access_token_relation")
207+
@console_ns.response(204, "Resource access token relation deleted")
208+
@console_account_admission(allowed_roles=_OWNER_ROLES)
209+
def delete(
210+
self,
211+
context: RequestContext,
212+
token_id: UUID,
213+
relation_id: UUID,
214+
) -> tuple[str, int]:
215+
with resource_access_token_errors():
216+
application_services().resource_access_tokens.delete_relation(
217+
context,
218+
token_id=str(token_id),
219+
relation_id=str(relation_id),
220+
)
221+
return "", 204

‎api/controllers/openapi/app_run.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
CheckSubject,
4747
CheckWorkspaceMember,
4848
)
49-
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
49+
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
5050
from controllers.openapi.human_input_form import with_form_hints
5151
from controllers.service_api.app.error import (
5252
AppUnavailableError,
@@ -133,7 +133,7 @@ def _translate_service_errors() -> Generator[None, None, None]:
133133

134134

135135
_RUN_GUARDS: Final = (
136-
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
136+
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
137137
CheckAppApiEnabled(),
138138
CheckWorkspaceMember(),
139139
CheckScope(Scope.APPS_RUN),
@@ -370,6 +370,10 @@ class AppRunTaskStopApi(Resource):
370370
returns=(200, TaskStopResponse, "Task stopped"),
371371
)
372372
def post(self, ctx: Context, app_id: str, task_id: str):
373+
if isinstance(ctx.subject, ResourceAccessSubject):
374+
owner = redis_client.get(AppQueueManager._generate_task_belong_cache_key(task_id))
375+
if owner != f"end-user-{ctx.end_user.id}".encode():
376+
raise NotFound("Task not found")
373377
AppQueueManager.set_stop_flag_no_user_check(task_id)
374378
GraphEngineManager(redis_client).send_stop_command(task_id)
375379
return TaskStopResponse(result="success")

‎api/controllers/openapi/apps.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
CheckSubject,
3434
CheckWorkspaceMember,
3535
)
36-
from controllers.openapi.auth.subjects import AccountSubject
36+
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
3737
from controllers.service_api.app.error import AppUnavailableError
3838
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
3939
from extensions.ext_application_services import application_services
@@ -115,7 +115,7 @@ class AppDescribeApi(Resource):
115115
),
116116
),
117117
requirements=(
118-
CheckSubject(allowed=(AccountSubject,)),
118+
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
119119
CheckAppApiEnabled(),
120120
CheckWorkspaceMember(),
121121
CheckScope(Scope.APPS_READ),
@@ -143,7 +143,7 @@ class AppListApi(Resource):
143143
),
144144
),
145145
requirements=(
146-
CheckSubject(allowed=(AccountSubject,)),
146+
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
147147
CheckScope(Scope.APPS_READ),
148148
CheckWorkspaceMember(),
149149
),
@@ -152,7 +152,7 @@ class AppListApi(Resource):
152152
)
153153
def get(self, ctx: Context, *, query: AppListQuery):
154154
workspace_id = query.workspace_id
155-
account_id = str(ctx.subject.account_id)
155+
account_id = str(ctx.subject.account_id or ctx.subject.token_id)
156156

157157
empty = AppListResponse.build(page=query.page, limit=query.limit, total=0, items=[])
158158

@@ -166,15 +166,22 @@ def get(self, ctx: Context, *, query: AppListQuery):
166166

167167
access_filter = (
168168
resolve_app_access_filter(workspace_id, account_id, session=ctx.session)
169-
if dify_config.RBAC_ENABLED
169+
if dify_config.RBAC_ENABLED and isinstance(ctx.subject, AccountSubject)
170170
else AppAccessFilter.unrestricted()
171171
)
172172

173+
if ctx.resource_app_ids is not None:
174+
access_filter = AppAccessFilter(set(ctx.resource_app_ids), can_manage_own_apps=False)
175+
173176
tenant_name: str | None = None
174177
if parsed_uuid is not None:
175178
app = application_services().apps.queries.get_visible_app_by_id(str(parsed_uuid), workspace_id)
176179
if app is None or str(app.tenant_id) != workspace_id:
177180
return empty
181+
if ctx.resource_app_ids is not None and (
182+
str(app.id) not in ctx.resource_app_ids or app.status != AppStatus.NORMAL
183+
):
184+
return empty
178185
if not _is_listable(app):
179186
return empty
180187
# Apply RBAC visibility to the UUID fast-path the same way the service

‎api/controllers/openapi/auth/context.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Context:
4343
view_args: Mapping[str, str]
4444
request_id: str = field(default_factory=get_request_id)
4545
trace_id: str | None = field(default_factory=get_trace_id)
46+
resource_app_ids: frozenset[str] | None = field(default=None, init=False)
4647
_app: App | None = field(default=None, init=False)
4748
_workspace: Tenant | None = field(default=None, init=False)
4849
_workspace_role: TenantAccountRole | None = field(default=None, init=False)

0 commit comments

Comments
 (0)