mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:36:31 +08:00
feat(api): add /openapi/v1/workspaces reads (Phase E.17)
GET /openapi/v1/workspaces lists tenants the bearer's account is a member of. GET /openapi/v1/workspaces/<id> returns one workspace detail, member-gated (404 on non-member, never 403, so workspace IDs don't leak across tenants). Bearer-authed via @validate_bearer(accept=ACCEPT_USER_ANY). External SSO bearers (no account_id) get an empty list / 404 — same posture as GET /openapi/v1/account. Cookie-authed /console/api/workspaces stays in console for the dashboard SPA — different consumer, different auth model. No legacy /v1/ remount this phase. Plan: docs/superpowers/plans/2026-04-26-openapi-migration.md (in difyctl repo).
This commit is contained in:
parent
2a38df2b7f
commit
a07b32274a
@ -16,13 +16,14 @@ api = ExternalApi(
|
||||
|
||||
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
|
||||
|
||||
from . import account, index, oauth_device, oauth_device_sso
|
||||
from . import account, index, oauth_device, oauth_device_sso, workspaces
|
||||
|
||||
__all__ = [
|
||||
"account",
|
||||
"index",
|
||||
"oauth_device",
|
||||
"oauth_device_sso",
|
||||
"workspaces",
|
||||
]
|
||||
|
||||
api.add_namespace(openapi_ns)
|
||||
|
||||
86
api/controllers/openapi/workspaces.py
Normal file
86
api/controllers/openapi/workspaces.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""User-scoped workspace reads under /openapi/v1/workspaces. Bearer-authed
|
||||
counterparts to the cookie-authed /console/api/workspaces endpoints.
|
||||
|
||||
Account bearers (dfoa_) see every tenant they're a member of. External
|
||||
SSO bearers (dfoe_) have no account_id and so see an empty list — that
|
||||
matches /openapi/v1/account.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import g
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.openapi import openapi_ns
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import (
|
||||
ACCEPT_USER_ANY,
|
||||
SubjectType,
|
||||
validate_bearer,
|
||||
)
|
||||
from models import Tenant, TenantAccountJoin
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces")
|
||||
class WorkspacesApi(Resource):
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
def get(self):
|
||||
ctx = g.auth_ctx
|
||||
if ctx.subject_type != SubjectType.ACCOUNT or not ctx.account_id:
|
||||
return {"workspaces": []}, 200
|
||||
|
||||
rows = db.session.execute(
|
||||
select(Tenant, TenantAccountJoin)
|
||||
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
|
||||
.where(TenantAccountJoin.account_id == str(ctx.account_id))
|
||||
.order_by(Tenant.created_at.asc())
|
||||
).all()
|
||||
|
||||
return {"workspaces": [_workspace_summary(t, m) for t, m in rows]}, 200
|
||||
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>")
|
||||
class WorkspaceByIdApi(Resource):
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
def get(self, workspace_id: str):
|
||||
ctx = g.auth_ctx
|
||||
# External SSO + missing account → never a member of anything; 404.
|
||||
if ctx.subject_type != SubjectType.ACCOUNT or not ctx.account_id:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
row = db.session.execute(
|
||||
select(Tenant, TenantAccountJoin)
|
||||
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
|
||||
.where(
|
||||
Tenant.id == workspace_id,
|
||||
TenantAccountJoin.account_id == str(ctx.account_id),
|
||||
)
|
||||
).first()
|
||||
# 404 (not 403) on non-member so workspace IDs don't leak across tenants.
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
tenant, membership = row
|
||||
return _workspace_detail(tenant, membership), 200
|
||||
|
||||
|
||||
def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> dict:
|
||||
return {
|
||||
"id": str(tenant.id),
|
||||
"name": tenant.name,
|
||||
"role": getattr(membership, "role", ""),
|
||||
"status": tenant.status,
|
||||
"current": getattr(membership, "current", False),
|
||||
}
|
||||
|
||||
|
||||
def _workspace_detail(tenant: Tenant, membership: TenantAccountJoin) -> dict:
|
||||
return {
|
||||
"id": str(tenant.id),
|
||||
"name": tenant.name,
|
||||
"role": getattr(membership, "role", ""),
|
||||
"status": tenant.status,
|
||||
"current": getattr(membership, "current", False),
|
||||
"created_at": tenant.created_at.isoformat() if tenant.created_at else None,
|
||||
}
|
||||
57
api/tests/unit_tests/controllers/openapi/test_workspaces.py
Normal file
57
api/tests/unit_tests/controllers/openapi/test_workspaces.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""Phase E step 17: workspace reads at /openapi/v1/workspaces. Bearer-authed
|
||||
list + member-gated detail. No legacy /v1/ equivalent — the cookie-authed
|
||||
/console/api/workspaces is a separate consumer that stays in console.
|
||||
"""
|
||||
import builtins
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.views import MethodView
|
||||
|
||||
from controllers.openapi import bp as openapi_bp
|
||||
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi
|
||||
|
||||
if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openapi_app() -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(openapi_bp)
|
||||
return app
|
||||
|
||||
|
||||
def _rule(app: Flask, path: str):
|
||||
return next(r for r in app.url_map.iter_rules() if r.rule == path)
|
||||
|
||||
|
||||
def test_workspaces_list_route_registered(openapi_app: Flask):
|
||||
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
||||
assert "/openapi/v1/workspaces" in rules
|
||||
|
||||
|
||||
def test_workspaces_list_dispatches_to_workspaces_api(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspacesApi
|
||||
assert "GET" in rule.methods
|
||||
|
||||
|
||||
def test_workspace_by_id_route_registered(openapi_app: Flask):
|
||||
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
||||
assert "/openapi/v1/workspaces/<string:workspace_id>" in rules
|
||||
|
||||
|
||||
def test_workspace_by_id_dispatches_to_correct_class(openapi_app: Flask):
|
||||
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>")
|
||||
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceByIdApi
|
||||
assert "GET" in rule.methods
|
||||
|
||||
|
||||
def test_console_legacy_workspaces_route_not_remounted_on_openapi(openapi_app: Flask):
|
||||
"""Phase E only adds the bearer-authed mounts on /openapi/v1/.
|
||||
The cookie-authed /console/api/workspaces stays where it is.
|
||||
"""
|
||||
rules = {r.rule for r in openapi_app.url_map.iter_rules()}
|
||||
assert "/console/api/workspaces" not in rules
|
||||
Loading…
Reference in New Issue
Block a user