"""GET /openapi/v1/apps/permitted — external-subject app discovery (EE only).""" from __future__ import annotations import sqlalchemy as sa from flask import request from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, ValidationError from werkzeug.exceptions import UnprocessableEntity from controllers.openapi import openapi_ns from controllers.openapi._models import ( MAX_PAGE_LIMIT, AppListRow, PaginationEnvelope, ) from extensions.ext_database import db from libs.device_flow_security import enterprise_only from libs.oauth_bearer import ( ACCEPT_USER_EXT_SSO, Scope, require_scope, validate_bearer, ) from models import App, Tenant from models.model import AppMode from services.enterprise.app_permitted_service import list_permitted_apps class AppPermittedListQuery(BaseModel): """Strict (`extra='forbid'`) — rejects `workspace_id`/`tag`/etc. that are valid on /apps but not here.""" model_config = ConfigDict(extra="forbid") page: int = Field(1, ge=1) limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) mode: AppMode | None = None name: str | None = Field(None, max_length=200) @openapi_ns.route("/apps/permitted") class AppPermittedListApi(Resource): method_decorators = [ require_scope(Scope.APPS_READ_PERMITTED), validate_bearer(accept=ACCEPT_USER_EXT_SSO), enterprise_only, ] def get(self): try: query = AppPermittedListQuery.model_validate(request.args.to_dict(flat=True)) except ValidationError as exc: raise UnprocessableEntity(exc.json()) page_result = list_permitted_apps( page=query.page, limit=query.limit, mode=query.mode.value if query.mode else None, name=query.name, ) if not page_result.app_ids: env = PaginationEnvelope[AppListRow].build( page=query.page, limit=query.limit, total=page_result.total, items=[] ) return env.model_dump(mode="json"), 200 apps_by_id = { str(a.id): a for a in db.session.execute(sa.select(App).where(App.id.in_(page_result.app_ids))).scalars().all() } tenant_ids = list({a.tenant_id for a in apps_by_id.values()}) tenants_by_id = { str(t.id): t for t in db.session.execute(sa.select(Tenant).where(Tenant.id.in_(tenant_ids))).scalars().all() } items: list[AppListRow] = [] for app_id in page_result.app_ids: app = apps_by_id.get(app_id) if not app or app.status != "normal": continue tenant = tenants_by_id.get(str(app.tenant_id)) items.append( AppListRow( id=str(app.id), name=app.name, description=app.description, mode=app.mode, tags=[], # tenant-scoped; not surfaced cross-tenant updated_at=app.updated_at.isoformat() if app.updated_at else None, created_by_name=None, # cross-tenant author leak prevention workspace_id=str(app.tenant_id), workspace_name=tenant.name if tenant else None, ) ) # total/has_more reflect the EE-side allow-list; len(items) may be < limit when local rows are dropped. env = PaginationEnvelope[AppListRow].build( page=query.page, limit=query.limit, total=page_result.total, items=items ) return env.model_dump(mode="json"), 200