From b7bd9c19ed282a377fd20174ee63f267948b0c37 Mon Sep 17 00:00:00 2001 From: GareArc Date: Sun, 26 Apr 2026 23:50:15 -0700 Subject: [PATCH] feat(api): lift identity + self-revoke to /openapi/v1/account (Phase C.9-10) GET /v1/me moves to GET /openapi/v1/account. DELETE /v1/oauth/authorizations/self moves to DELETE /openapi/v1/account/sessions/self. Both classes (AccountApi, AccountSessionsSelfApi) are now in controllers/openapi/account.py and re-registered on service_api_ns at the legacy paths. service_api/oauth.py is now nothing but legacy re-mount declarations (20 lines). All in-place handler logic has moved to openapi/. Phase F will delete the file and the legacy mounts together. Helper functions (_load_memberships, _pick_default_workspace, _workspace_payload, _account_payload) move with the AccountApi class. Plan: docs/superpowers/plans/2026-04-26-openapi-migration.md (in difyctl repo). --- api/controllers/openapi/__init__.py | 3 +- api/controllers/openapi/account.py | 137 ++++++++++++++++ api/controllers/service_api/oauth.py | 155 +----------------- .../controllers/openapi/test_account.py | 73 +++++++++ 4 files changed, 220 insertions(+), 148 deletions(-) create mode 100644 api/controllers/openapi/account.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_account.py diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index a28faeeae6..46c5dd9a7e 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -14,12 +14,13 @@ api = ExternalApi( openapi_ns = Namespace("openapi", description="User-scoped operations", path="/") -from . import index +from . import account, index from .oauth_device import code as oauth_device_code from .oauth_device import lookup as oauth_device_lookup from .oauth_device import token as oauth_device_token __all__ = [ + "account", "index", "oauth_device_code", "oauth_device_lookup", diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py new file mode 100644 index 0000000000..49a5d888e3 --- /dev/null +++ b/api/controllers/openapi/account.py @@ -0,0 +1,137 @@ +"""User-scoped account endpoints. /account is the bearer-authed +identity read; /account/sessions and /account/sessions/ manage +the user's active OAuth tokens (Phase C steps 11–12). + +The /account class is also registered on the legacy /v1/me path from +service_api/oauth.py until Phase F retires that mount. Likewise +/account/sessions/self is re-mounted at /v1/oauth/authorizations/self. +""" +from __future__ import annotations + +from datetime import UTC, datetime + +from flask import g +from flask_restx import Resource +from sqlalchemy import update +from werkzeug.exceptions import BadRequest + +from controllers.openapi import openapi_ns +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + SubjectType, + TOKEN_CACHE_KEY_FMT, + validate_bearer, +) +from libs.rate_limit import ( + LIMIT_ME_PER_ACCOUNT, + LIMIT_ME_PER_EMAIL, + enforce, +) +from models import Account, OAuthAccessToken, Tenant, TenantAccountJoin + + +@openapi_ns.route("/account") +class AccountApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + def get(self): + ctx = g.auth_ctx + + if ctx.subject_type == SubjectType.EXTERNAL_SSO: + enforce(LIMIT_ME_PER_EMAIL, key=f"subject:{ctx.subject_email}") + else: + enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{ctx.account_id}") + + if ctx.subject_type == SubjectType.EXTERNAL_SSO: + return { + "subject_type": ctx.subject_type, + "subject_email": ctx.subject_email, + "subject_issuer": ctx.subject_issuer, + "account": None, + "workspaces": [], + "default_workspace_id": None, + } + + account = ( + db.session.query(Account).filter(Account.id == ctx.account_id).one_or_none() + if ctx.account_id else None + ) + memberships = _load_memberships(ctx.account_id) if ctx.account_id else [] + default_ws_id = _pick_default_workspace(memberships) + + return { + "subject_type": ctx.subject_type, + "subject_email": ctx.subject_email or (account.email if account else None), + "account": _account_payload(account) if account else None, + "workspaces": [_workspace_payload(m) for m in memberships], + "default_workspace_id": default_ws_id, + } + + +@openapi_ns.route("/account/sessions/self") +class AccountSessionsSelfApi(Resource): + @validate_bearer(accept=ACCEPT_USER_ANY) + def delete(self): + ctx = g.auth_ctx + + if not ctx.source.startswith("oauth"): + raise BadRequest( + "this endpoint revokes OAuth bearer tokens; " + "use /openapi/v1/personal-access-tokens/self for PATs" + ) + + # Snapshot pre-revoke hash for cache invalidation; UPDATE WHERE + # makes double-revoke idempotent. + row = ( + db.session.query(OAuthAccessToken.token_hash) + .filter( + OAuthAccessToken.id == str(ctx.token_id), + OAuthAccessToken.revoked_at.is_(None), + ) + .one_or_none() + ) + pre_revoke_hash = row[0] if row else None + + stmt = ( + update(OAuthAccessToken) + .where( + OAuthAccessToken.id == str(ctx.token_id), + OAuthAccessToken.revoked_at.is_(None), + ) + .values(revoked_at=datetime.now(UTC), token_hash=None) + ) + db.session.execute(stmt) + db.session.commit() + + if pre_revoke_hash: + redis_client.delete(TOKEN_CACHE_KEY_FMT.format(hash=pre_revoke_hash)) + + return {"status": "revoked"}, 200 + + +def _load_memberships(account_id): + return ( + db.session.query(TenantAccountJoin, Tenant) + .join(Tenant, Tenant.id == TenantAccountJoin.tenant_id) + .filter(TenantAccountJoin.account_id == account_id) + .all() + ) + + +def _pick_default_workspace(memberships) -> str | None: + if not memberships: + return None + for join, tenant in memberships: + if getattr(join, "current", False): + return str(tenant.id) + return str(memberships[0][1].id) + + +def _workspace_payload(row) -> dict: + join, tenant = row + return {"id": str(tenant.id), "name": tenant.name, "role": getattr(join, "role", "")} + + +def _account_payload(account) -> dict: + return {"id": str(account.id), "email": account.email, "name": account.name} diff --git a/api/controllers/service_api/oauth.py b/api/controllers/service_api/oauth.py index bf6ff04d95..d10e8bf8eb 100644 --- a/api/controllers/service_api/oauth.py +++ b/api/controllers/service_api/oauth.py @@ -1,159 +1,20 @@ -"""``/v1`` OAuth bearer + device-flow endpoints. ``/me`` and self-revoke -are bearer-authed; the device-flow trio (code/token/lookup) is public — -code/token per RFC 8628, lookup so the /device page can pre-validate -before the user has a console session. +"""Legacy /v1/* mounts for the OAuth bearer + device-flow endpoints. +Canonical handlers live in controllers/openapi/. This file just +re-registers them on the service_api_ns until Phase F retires the +legacy paths entirely. """ from __future__ import annotations -import logging -from datetime import UTC, datetime - -from flask import g -from flask_restx import Resource -from sqlalchemy import update -from werkzeug.exceptions import BadRequest - +from controllers.openapi.account import AccountApi, AccountSessionsSelfApi from controllers.openapi.oauth_device.code import OAuthDeviceCodeApi from controllers.openapi.oauth_device.lookup import OAuthDeviceLookupApi from controllers.openapi.oauth_device.token import OAuthDeviceTokenApi from controllers.service_api import service_api_ns -from extensions.ext_database import db -from extensions.ext_redis import redis_client -from libs.oauth_bearer import ( - ACCEPT_USER_ANY, - SubjectType, - TOKEN_CACHE_KEY_FMT, - validate_bearer, -) -from libs.rate_limit import ( - LIMIT_ME_PER_ACCOUNT, - LIMIT_ME_PER_EMAIL, - enforce, -) -from models import Account, OAuthAccessToken, Tenant, TenantAccountJoin -logger = logging.getLogger(__name__) - -# Legacy /v1/* mounts — handlers live in controllers/openapi/oauth_device/. +# Legacy /v1/* mounts — handlers live in controllers/openapi/. # Removed in Phase F. service_api_ns.add_resource(OAuthDeviceCodeApi, "/oauth/device/code") service_api_ns.add_resource(OAuthDeviceTokenApi, "/oauth/device/token") service_api_ns.add_resource(OAuthDeviceLookupApi, "/oauth/device/lookup") - - -# ============================================================================ -# GET /v1/me -# ============================================================================ - - -@service_api_ns.route("/me") -class MeApi(Resource): - @validate_bearer(accept=ACCEPT_USER_ANY) - def get(self): - ctx = g.auth_ctx - - if ctx.subject_type == SubjectType.EXTERNAL_SSO: - enforce(LIMIT_ME_PER_EMAIL, key=f"subject:{ctx.subject_email}") - else: - enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{ctx.account_id}") - - if ctx.subject_type == SubjectType.EXTERNAL_SSO: - return { - "subject_type": ctx.subject_type, - "subject_email": ctx.subject_email, - "subject_issuer": ctx.subject_issuer, - "account": None, - "workspaces": [], - "default_workspace_id": None, - } - - account = ( - db.session.query(Account).filter(Account.id == ctx.account_id).one_or_none() - if ctx.account_id else None - ) - memberships = _load_memberships(ctx.account_id) if ctx.account_id else [] - default_ws_id = _pick_default_workspace(memberships) - - return { - "subject_type": ctx.subject_type, - "subject_email": ctx.subject_email or (account.email if account else None), - "account": _account_payload(account) if account else None, - "workspaces": [_workspace_payload(m) for m in memberships], - "default_workspace_id": default_ws_id, - } - - -def _load_memberships(account_id): - return ( - db.session.query(TenantAccountJoin, Tenant) - .join(Tenant, Tenant.id == TenantAccountJoin.tenant_id) - .filter(TenantAccountJoin.account_id == account_id) - .all() - ) - - -def _pick_default_workspace(memberships) -> str | None: - if not memberships: - return None - for join, tenant in memberships: - if getattr(join, "current", False): - return str(tenant.id) - return str(memberships[0][1].id) - - -def _workspace_payload(row) -> dict: - join, tenant = row - return {"id": str(tenant.id), "name": tenant.name, "role": getattr(join, "role", "")} - - -def _account_payload(account) -> dict: - return {"id": str(account.id), "email": account.email, "name": account.name} - - -# ============================================================================ -# DELETE /v1/oauth/authorizations/self -# ============================================================================ - - -@service_api_ns.route("/oauth/authorizations/self") -class OAuthAuthorizationsSelfApi(Resource): - @validate_bearer(accept=ACCEPT_USER_ANY) - def delete(self): - ctx = g.auth_ctx - - if not ctx.source.startswith("oauth"): - raise BadRequest( - "this endpoint revokes OAuth bearer tokens; " - "use /v1/personal-access-tokens/self for PATs" - ) - - # Snapshot pre-revoke hash for cache invalidation; UPDATE WHERE - # makes double-revoke idempotent. - row = ( - db.session.query(OAuthAccessToken.token_hash) - .filter( - OAuthAccessToken.id == str(ctx.token_id), - OAuthAccessToken.revoked_at.is_(None), - ) - .one_or_none() - ) - pre_revoke_hash = row[0] if row else None - - stmt = ( - update(OAuthAccessToken) - .where( - OAuthAccessToken.id == str(ctx.token_id), - OAuthAccessToken.revoked_at.is_(None), - ) - .values(revoked_at=datetime.now(UTC), token_hash=None) - ) - db.session.execute(stmt) - db.session.commit() - - if pre_revoke_hash: - redis_client.delete(TOKEN_CACHE_KEY_FMT.format(hash=pre_revoke_hash)) - - return {"status": "revoked"}, 200 - - - +service_api_ns.add_resource(AccountApi, "/me") +service_api_ns.add_resource(AccountSessionsSelfApi, "/oauth/authorizations/self") diff --git a/api/tests/unit_tests/controllers/openapi/test_account.py b/api/tests/unit_tests/controllers/openapi/test_account.py new file mode 100644 index 0000000000..3fbde1598b --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_account.py @@ -0,0 +1,73 @@ +"""Phase C steps 9–10: identity + self-revoke moved to /openapi/v1/account. +Legacy /v1/me + /v1/oauth/authorizations/self stay mounted via +re-registration in service_api/oauth.py. +""" +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.account import AccountApi, AccountSessionsSelfApi +from controllers.service_api import bp as service_api_bp + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def dual_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(service_api_bp) + 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_account_route_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/openapi/v1/account" in rules + + +def test_legacy_me_route_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/v1/me" in rules + + +def test_account_and_me_dispatch_to_same_class(dual_app: Flask): + new = _rule(dual_app, "/openapi/v1/account") + legacy = _rule(dual_app, "/v1/me") + assert dual_app.view_functions[new.endpoint].view_class is AccountApi + assert dual_app.view_functions[legacy.endpoint].view_class is AccountApi + + +def test_account_sessions_self_route_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/openapi/v1/account/sessions/self" in rules + + +def test_legacy_oauth_authorizations_self_route_registered(dual_app: Flask): + rules = {r.rule for r in dual_app.url_map.iter_rules()} + assert "/v1/oauth/authorizations/self" in rules + + +def test_sessions_self_paths_dispatch_to_same_class(dual_app: Flask): + new = _rule(dual_app, "/openapi/v1/account/sessions/self") + legacy = _rule(dual_app, "/v1/oauth/authorizations/self") + assert dual_app.view_functions[new.endpoint].view_class is AccountSessionsSelfApi + assert dual_app.view_functions[legacy.endpoint].view_class is AccountSessionsSelfApi + + +def test_account_methods(dual_app: Flask): + rule = _rule(dual_app, "/openapi/v1/account") + assert "GET" in rule.methods + + +def test_sessions_self_methods(dual_app: Flask): + rule = _rule(dual_app, "/openapi/v1/account/sessions/self") + assert "DELETE" in rule.methods