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

@ -0,0 +1,44 @@
"""Unit tests for AppPermittedListQuery — the /apps/permitted query validator.
Strict ConfigDict(extra='forbid'): cross-tenant tag/workspace_id are
unresolvable, so the model must reject them as 422 instead of silently
dropping them. Mode/name/page/limit have the same shape as AppListQuery.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from controllers.openapi.apps_permitted import AppPermittedListQuery
def test_query_defaults_match_apps_list():
q = AppPermittedListQuery.model_validate({})
assert q.page == 1
assert q.limit == 20
assert q.mode is None
assert q.name is None
def test_query_rejects_workspace_id():
"""workspace_id is meaningless for /permitted (cross-tenant); rejecting it
forces CLI authors to drop the param rather than send it silently."""
with pytest.raises(ValidationError):
AppPermittedListQuery.model_validate({"workspace_id": "ws-1"})
def test_query_rejects_tag():
"""Tags are tenant-scoped; cross-tenant tag resolution is undefined."""
with pytest.raises(ValidationError):
AppPermittedListQuery.model_validate({"tag": "prod"})
def test_query_validates_mode_against_app_mode():
with pytest.raises(ValidationError):
AppPermittedListQuery.model_validate({"mode": "not-a-mode"})
def test_query_clamps_limit_at_max():
with pytest.raises(ValidationError):
AppPermittedListQuery.model_validate({"limit": 500})