Files
dify/api/controllers/openapi/auth/resource_access.py

92 lines
3.7 KiB
Python

"""Resource-token authentication and live resource authorization for OpenAPI.
Machine credentials never inherit their creator's account or workspace role.
Re-read bindings on every request so revocation takes effect without cache lag.
"""
import uuid
from typing import override
from flask import request
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, Unauthorized
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import Rank, Requirement
from controllers.openapi.auth.subjects import Subject
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from libs.oauth_bearer import AuthContext, TokenType, sha256_hex
from libs.rate_limit import enforce_bearer_rate_limit
from models.account import Tenant, TenantStatus
from models.enums import AppStatus
from models.model import App
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
def authenticate_resource_token(token: str) -> AuthContext:
token_hash = sha256_hex(token)
enforce_bearer_rate_limit(token_hash)
with session_factory.create_session() as session:
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
if row is None:
raise Unauthorized("invalid_bearer")
token_id = uuid.UUID(str(row.id))
return AuthContext(
subject_email=None,
subject_issuer=None,
account_id=None,
client_id=None,
token_id=token_id,
token_type=TokenType.RESOURCE_ACCESS,
expires_at=None,
)
class CheckResourceAccess(Requirement):
# CheckSubject rejects unsupported routes first, then bindings are loaded
# before any app or workspace requirements can read their context.
rank = Rank.FIRST
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.id == str(subject.token_id)))
if row is None:
raise Unauthorized("invalid_bearer")
tenant = session.get(Tenant, row.tenant_id)
if tenant is None or tenant.status != TenantStatus.NORMAL:
raise Forbidden("workspace unavailable")
workspace_id = ctx.view_args.get("workspace_id") or request.args.get("workspace_id")
if workspace_id and workspace_id != str(tenant.id):
raise Forbidden("resource_not_authorized")
ctx._workspace = tenant
ctx.resource_app_ids = frozenset(
session.scalars(
select(App.id)
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.app_id == App.id)
.where(
ResourceAccessTokenRelation.token_id == row.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
App.tenant_id == tenant.id,
)
)
)
app_id = ctx.view_args.get("app_id")
if app_id:
try:
app_id = str(uuid.UUID(app_id))
except ValueError:
raise Forbidden("resource_not_authorized")
if app_id not in ctx.resource_app_ids:
raise Forbidden("resource_not_authorized")
app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant.id))
if app is None or app.status != AppStatus.NORMAL or not app.enable_api:
raise Forbidden("resource_not_authorized")
ctx._app = app
row.last_used_at = naive_utc_now()