mirror of
https://github.com/langgenius/dify.git
synced 2026-05-13 08:57:28 +08:00
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).
This commit is contained in:
parent
e93821af46
commit
b7bd9c19ed
@ -14,12 +14,13 @@ api = ExternalApi(
|
|||||||
|
|
||||||
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
|
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 code as oauth_device_code
|
||||||
from .oauth_device import lookup as oauth_device_lookup
|
from .oauth_device import lookup as oauth_device_lookup
|
||||||
from .oauth_device import token as oauth_device_token
|
from .oauth_device import token as oauth_device_token
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"account",
|
||||||
"index",
|
"index",
|
||||||
"oauth_device_code",
|
"oauth_device_code",
|
||||||
"oauth_device_lookup",
|
"oauth_device_lookup",
|
||||||
|
|||||||
137
api/controllers/openapi/account.py
Normal file
137
api/controllers/openapi/account.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"""User-scoped account endpoints. /account is the bearer-authed
|
||||||
|
identity read; /account/sessions and /account/sessions/<id> 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}
|
||||||
@ -1,159 +1,20 @@
|
|||||||
"""``/v1`` OAuth bearer + device-flow endpoints. ``/me`` and self-revoke
|
"""Legacy /v1/* mounts for the OAuth bearer + device-flow endpoints.
|
||||||
are bearer-authed; the device-flow trio (code/token/lookup) is public —
|
Canonical handlers live in controllers/openapi/. This file just
|
||||||
code/token per RFC 8628, lookup so the /device page can pre-validate
|
re-registers them on the service_api_ns until Phase F retires the
|
||||||
before the user has a console session.
|
legacy paths entirely.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from controllers.openapi.account import AccountApi, AccountSessionsSelfApi
|
||||||
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.oauth_device.code import OAuthDeviceCodeApi
|
from controllers.openapi.oauth_device.code import OAuthDeviceCodeApi
|
||||||
from controllers.openapi.oauth_device.lookup import OAuthDeviceLookupApi
|
from controllers.openapi.oauth_device.lookup import OAuthDeviceLookupApi
|
||||||
from controllers.openapi.oauth_device.token import OAuthDeviceTokenApi
|
from controllers.openapi.oauth_device.token import OAuthDeviceTokenApi
|
||||||
from controllers.service_api import service_api_ns
|
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/.
|
||||||
|
|
||||||
# Legacy /v1/* mounts — handlers live in controllers/openapi/oauth_device/.
|
|
||||||
# Removed in Phase F.
|
# Removed in Phase F.
|
||||||
service_api_ns.add_resource(OAuthDeviceCodeApi, "/oauth/device/code")
|
service_api_ns.add_resource(OAuthDeviceCodeApi, "/oauth/device/code")
|
||||||
service_api_ns.add_resource(OAuthDeviceTokenApi, "/oauth/device/token")
|
service_api_ns.add_resource(OAuthDeviceTokenApi, "/oauth/device/token")
|
||||||
service_api_ns.add_resource(OAuthDeviceLookupApi, "/oauth/device/lookup")
|
service_api_ns.add_resource(OAuthDeviceLookupApi, "/oauth/device/lookup")
|
||||||
|
service_api_ns.add_resource(AccountApi, "/me")
|
||||||
|
service_api_ns.add_resource(AccountSessionsSelfApi, "/oauth/authorizations/self")
|
||||||
# ============================================================================
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
73
api/tests/unit_tests/controllers/openapi/test_account.py
Normal file
73
api/tests/unit_tests/controllers/openapi/test_account.py
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user