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:
GareArc 2026-04-27 00:10:16 -07:00
parent 2a38df2b7f
commit a07b32274a
No known key found for this signature in database
3 changed files with 145 additions and 1 deletions

View File

@ -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)

View 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,
}

View 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