feat(openapi): /apps/permitted — external-subject app discovery (EE)

Split route for dfoe_ external-SSO discovery, separate from /apps
(dfoa_-only workspace catalog). Cross-tenant allow-list query: server
calls Enterprise inner-API POST /inner/api/webapp/permitted-apps and
hydrates app/tenant rows locally. New scope apps:read:permitted (no
dual-meaning with apps:read). Route gated by @enterprise_only — 404
on CE — and validate_bearer(accept=ACCEPT_USER_EXT_SSO) — 403 on dfoa_.
Query validator rejects workspace_id and tag (cross-tenant
unresolvable); mode/name supported.

EE inner-API wire-up depends on ee-2; the service-layer stub raises
ServiceUnavailable until that endpoint ships. CLI dispatches between
/apps and /apps/permitted client-side based on the bearer prefix in
hosts.yml — see docs/specs/v1.0/apps.md §Subject dispatch.

Verified via unit tests on AppPermittedListQuery and Scope wiring;
HTTP integration tests deferred to ee-2 once the inner-API ships.
This commit is contained in:
GareArc
2026-05-05 20:20:22 -07:00
parent 6f3c2fe97b
commit 04ebf8a92f
8 changed files with 229 additions and 3 deletions

View File

@ -41,7 +41,7 @@ from libs.oauth_bearer import (
require_workspace_member,
validate_bearer,
)
from models import App
from models import App, Tenant
from models.model import AppMode, Tag, TagBinding
# Shared decorator stack for `apps:read`-scoped endpoints. List order is
@ -185,6 +185,8 @@ class AppListApi(Resource):
workspace_id = query.workspace_id
require_workspace_member(ctx, workspace_id)
tenant_name = db.session.execute(sa.select(Tenant.name).where(Tenant.id == workspace_id)).scalar_one_or_none()
page = query.page
limit = query.limit
mode = query.mode.value if query.mode else None
@ -228,6 +230,8 @@ class AppListApi(Resource):
tags=[{"name": t.name} for t in r.tags],
updated_at=r.updated_at.isoformat() if r.updated_at else None,
created_by_name=getattr(r, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
for r in rows
]