mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
2601 lines
106 KiB
Python
2601 lines
106 KiB
Python
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import uuid
|
|
from collections.abc import Callable, Iterator
|
|
from contextlib import ExitStack
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timedelta
|
|
from enum import StrEnum, auto
|
|
from functools import singledispatch
|
|
from typing import Final
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from flask_login import LoginManager, user_logged_in
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
from werkzeug.exceptions import Forbidden, ImATeapot, NotFound
|
|
from werkzeug.routing import Rule
|
|
from werkzeug.test import TestResponse
|
|
|
|
import libs.oauth_bearer as oauth_bearer_module
|
|
import libs.rate_limit as rate_limit_module
|
|
from app_factory import create_flask_app_with_configs
|
|
from constants.oauth_bearer import Scope, TokenType
|
|
from controllers.common.rbac import AgentBehindApp, PlainApp, RBACCheck, RBACPermission, Workspace
|
|
from controllers.openapi import bp as openapi_bp
|
|
from controllers.openapi._app_settings import REGULAR_MODES
|
|
from controllers.openapi._catalog import CATALOG_HEADER, catalog_for
|
|
from controllers.openapi._contract import CapRequestBody, op_of
|
|
from controllers.openapi._errors import (
|
|
OpenApiErrorCode,
|
|
OpenApiErrorFormatter,
|
|
ServiceApiDisabled,
|
|
SkillsDisabled,
|
|
WebAppAccessRequiresEE,
|
|
)
|
|
from controllers.openapi._models import ServiceApiPatch
|
|
from controllers.openapi.app_info import AgentServiceApiApi, ServiceApiApi
|
|
from controllers.openapi.auth import subjects
|
|
from controllers.openapi.auth.context import RouteContractError
|
|
from controllers.openapi.auth.requirements import (
|
|
CheckAppAccess,
|
|
CheckAppApiEnabled,
|
|
CheckAppMode,
|
|
CheckAppQuota,
|
|
CheckPluginInstallSetting,
|
|
CheckRBACPermission,
|
|
CheckScope,
|
|
CheckSkillEnabled,
|
|
CheckSubject,
|
|
CheckWebAppAuthEnterprise,
|
|
CheckWorkspaceInvitationQuota,
|
|
CheckWorkspaceMember,
|
|
CheckWorkspaceRole,
|
|
Requirement,
|
|
assert_license_valid,
|
|
)
|
|
from controllers.openapi.auth.spec import EndpointSpec
|
|
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
|
|
from controllers.openapi.human_input_form import CheckFormSurface
|
|
from enums import DeploymentEdition
|
|
from enums import WebAppAccessMode as _auth_matrix_WebAppAccessMode
|
|
from extensions.ext_application_services import ApplicationServices
|
|
from libs.oauth_bearer import BearerAuthenticator, ResolvedRow, sha256_hex
|
|
from models import AppMode as _requirements_AppMode
|
|
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
|
|
from models.enums import EndUserType
|
|
from models.model import App, EndUser
|
|
from models.model import AppMode as _auth_matrix_AppMode
|
|
from models.oauth import OAuthAccessToken
|
|
from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings
|
|
from services.enterprise.enterprise_service import WebAppAccessMode as _requirements_WebAppAccessMode
|
|
from services.entities.feature_entities import (
|
|
LicenseStatus,
|
|
SystemFeatureModel,
|
|
)
|
|
from services.system_feature_service import SystemFeatureService
|
|
from tests.helpers.unit.config_override import apply_config_overrides
|
|
from tests.helpers.unit.controllers.openapi.auth._world import (
|
|
APP_ID,
|
|
CLIENT_ID,
|
|
TOKEN_ID,
|
|
account_subject,
|
|
make_account,
|
|
make_app,
|
|
make_ctx,
|
|
make_membership,
|
|
make_tenant,
|
|
never_reached,
|
|
persist,
|
|
sso_subject,
|
|
system_features,
|
|
webapp_settings,
|
|
)
|
|
|
|
FEATURES = "controllers.openapi.auth.requirements.SystemFeatureService.get_public_system_features"
|
|
WEBAPP_AUTH = "controllers.openapi.auth.requirements.EnterpriseService.WebAppAuth"
|
|
ACCESS_MODE = f"{WEBAPP_AUTH}.get_app_access_mode_by_id"
|
|
ENFORCE_RBAC = "controllers.openapi.auth.requirements.enforce_rbac_checks"
|
|
APP_FETCH = "controllers.openapi.auth.loaders.AppService.get_app_by_id"
|
|
|
|
|
|
class TestRequirementsCases:
|
|
@staticmethod
|
|
def test_subject_check_emits_the_wrong_surface_audit(app: Flask, sqlite_session: Session) -> None:
|
|
subject = sso_subject()
|
|
|
|
with app.test_request_context("/openapi/v1/account"):
|
|
with patch("controllers.openapi.auth.requirements.emit_wrong_surface") as emit:
|
|
requirement = CheckSubject(allowed=[AccountSubject])
|
|
with pytest.raises(Forbidden, match="unsupported_token_type"):
|
|
requirement.run(subject, make_ctx(sqlite_session, subject=subject), sqlite_session)
|
|
|
|
emit.assert_called_once_with(
|
|
subject_type="external_sso",
|
|
attempted_path="/openapi/v1/account",
|
|
client_id=CLIENT_ID,
|
|
token_id=TOKEN_ID,
|
|
)
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
("status", "denied"),
|
|
[
|
|
(LicenseStatus.INACTIVE, True),
|
|
(LicenseStatus.LOST, True),
|
|
(LicenseStatus.NONE, False),
|
|
],
|
|
)
|
|
def test_assert_license_valid_denies_only_dead_licences(status: LicenseStatus, denied: bool) -> None:
|
|
with patch(FEATURES, return_value=system_features(license_status=status)):
|
|
if denied:
|
|
with pytest.raises(Forbidden, match="license_invalid"):
|
|
assert_license_valid()
|
|
else:
|
|
assert_license_valid()
|
|
|
|
@staticmethod
|
|
def test_an_app_requirement_off_an_app_route_is_a_wiring_bug(
|
|
sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""App requirements are declared per endpoint, so a route with no `app_id`
|
|
can only carry one by mistake. It raises rather than passing quietly:
|
|
skipping would turn a misdeclaration into a check that silently never runs.
|
|
"""
|
|
monkeypatch.setattr(APP_FETCH, never_reached)
|
|
subject = account_subject()
|
|
|
|
with pytest.raises(RouteContractError, match="app_id is not a path parameter"):
|
|
CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject), sqlite_session)
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
("mode", "view"),
|
|
[
|
|
(_requirements_AppMode.CHAT, ServiceApiApi.patch),
|
|
(_requirements_AppMode.WORKFLOW, ServiceApiApi.patch),
|
|
(_requirements_AppMode.AGENT, AgentServiceApiApi.patch),
|
|
],
|
|
)
|
|
def test_a_disabled_service_api_hints_the_op_that_turns_it_on(
|
|
sqlite_session: Session, mode: _requirements_AppMode, view: Callable[..., object]
|
|
) -> None:
|
|
persist(sqlite_session, make_app(enable_api=False, mode=mode))
|
|
subject = account_subject()
|
|
|
|
with pytest.raises(ServiceApiDisabled) as raised:
|
|
CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session)
|
|
|
|
body = OpenApiErrorFormatter().finalize(raised.value, {}, raised.value.code or 0)
|
|
assert (body["status"], body["code"]) == (403, OpenApiErrorCode.SERVICE_API_DISABLED)
|
|
[hint] = body["hints"]
|
|
assert hint["op"] == op_of(view)
|
|
assert hint["input"] == {"app_id": APP_ID, "enabled": True}
|
|
ServiceApiPatch.model_validate({k: v for k, v in hint["input"].items() if k != "app_id"})
|
|
|
|
@staticmethod
|
|
def test_an_enabled_service_api_passes(sqlite_session: Session) -> None:
|
|
persist(sqlite_session, make_app(enable_api=True))
|
|
subject = account_subject()
|
|
|
|
CheckAppApiEnabled().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session)
|
|
|
|
@staticmethod
|
|
def test_check_skill_enabled_follows_the_server_flag(
|
|
sqlite_session: Session, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
subject = account_subject()
|
|
ctx = make_ctx(sqlite_session, subject=subject)
|
|
|
|
config_overrides(ENABLE_SKILL=True)
|
|
CheckSkillEnabled().run(subject, ctx, sqlite_session)
|
|
config_overrides(ENABLE_SKILL=False)
|
|
with pytest.raises(SkillsDisabled):
|
|
CheckSkillEnabled().run(subject, ctx, sqlite_session)
|
|
|
|
class TestCheckRBACPermission:
|
|
@staticmethod
|
|
def _requirement() -> CheckRBACPermission:
|
|
return CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
|
|
|
|
def test_skips_a_non_account_caller(
|
|
self, sqlite_session: Session, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
"""No matrix row reaches this: on the routes an SSO token can address,
|
|
RBAC permission either does not apply or no permission is declared, so an SSO caller falling
|
|
into account-scoped RBAC is invisible there.
|
|
"""
|
|
config_overrides(RBAC_ENABLED=True)
|
|
subject = sso_subject()
|
|
|
|
with patch(ENFORCE_RBAC) as enforce:
|
|
self._requirement().run(
|
|
subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session
|
|
)
|
|
|
|
enforce.assert_not_called()
|
|
|
|
def test_is_inert_wherever_rbac_is_off(
|
|
self, app: Flask, sqlite_session: Session, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
"""No matrix row reaches this: every row runs against a stubbed RBAC
|
|
backend, so a permission that enforced where RBAC is switched off would still
|
|
be admitted there. Standing down is what leaves the `CheckWorkspaceRole`
|
|
beside it as the only arm there.
|
|
"""
|
|
config_overrides(RBAC_ENABLED=False)
|
|
persist(sqlite_session, make_app(), make_tenant(), make_account())
|
|
subject = account_subject()
|
|
ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID)
|
|
|
|
with app.test_request_context(f"/openapi/v1/apps/{APP_ID}"):
|
|
with patch(ENFORCE_RBAC) as enforce:
|
|
self._requirement().run(subject, ctx, sqlite_session)
|
|
|
|
enforce.assert_not_called()
|
|
|
|
class TestCheckWorkspaceRole:
|
|
admin_only = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
|
|
|
def test_skips_a_non_account_caller(self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No matrix row reaches this either — and the workspace role check must not even fetch
|
|
the app to decide it.
|
|
"""
|
|
monkeypatch.setattr(APP_FETCH, never_reached)
|
|
subject = sso_subject()
|
|
|
|
CheckWorkspaceRole(self.admin_only).run(
|
|
subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session
|
|
)
|
|
|
|
def test_the_workspace_role_reads_the_role_through_the_loader(
|
|
self, app: Flask, sqlite_session: Session, config_overrides: Callable[..., None]
|
|
) -> None:
|
|
"""The answer is `load_workspace_role`'s, not a direct role read's: an
|
|
account that is not `ACTIVE` is a non-member, and hears 404 rather than
|
|
the workspace role check's own 403.
|
|
|
|
No matrix row reaches this. An `EARLY` membership check pre-empts the
|
|
`NORMAL` workspace role check on every shipped route, and the matrix mints only `ACTIVE`
|
|
accounts — so this is the only thing standing between the workspace role check and a
|
|
refactor that calls `get_account_role_in_tenant` itself, which would
|
|
re-admit a banned admin and answer 403 where the surface answers 404.
|
|
"""
|
|
config_overrides(RBAC_ENABLED=False)
|
|
persist(
|
|
sqlite_session,
|
|
make_app(),
|
|
make_tenant(),
|
|
make_account(status=AccountStatus.BANNED),
|
|
make_membership(TenantAccountRole.ADMIN),
|
|
)
|
|
subject = account_subject()
|
|
ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID)
|
|
|
|
with app.test_request_context(f"/openapi/v1/apps/{APP_ID}"):
|
|
with pytest.raises(NotFound, match="workspace not found"):
|
|
CheckWorkspaceRole(self.admin_only).run(subject, ctx, sqlite_session)
|
|
|
|
class TestCheckAppAccess:
|
|
@pytest.fixture(autouse=True)
|
|
def _enterprise(self, config_overrides: Callable[..., None]) -> None:
|
|
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE)
|
|
|
|
def test_no_ops_outside_enterprise(
|
|
self, sqlite_session: Session, config_overrides: Callable[..., None], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The named guard for the CE contract: the access-mode service is never
|
|
reached at all. Dropping the early return is otherwise only caught
|
|
incidentally, by an unrelated test that happens not to stub it.
|
|
"""
|
|
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
|
|
monkeypatch.setattr(ACCESS_MODE, never_reached)
|
|
persist(sqlite_session, make_app())
|
|
subject = account_subject()
|
|
|
|
CheckAppAccess().run(subject, make_ctx(sqlite_session, subject=subject, app_id=APP_ID), sqlite_session)
|
|
|
|
@pytest.mark.parametrize(
|
|
("settings", "failure"),
|
|
[
|
|
(webapp_settings("a-mode-this-build-has-never-heard-of"), None),
|
|
(None, ValueError("enterprise said no")),
|
|
],
|
|
ids=["unknown mode", "service error"],
|
|
)
|
|
def test_rejects_an_access_mode_it_could_not_load(
|
|
self, sqlite_session: Session, settings: WebAppSettings | None, failure: Exception | None
|
|
) -> None:
|
|
persist(sqlite_session, make_app())
|
|
subject = account_subject()
|
|
ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID)
|
|
|
|
with patch(FEATURES, return_value=system_features(webapp_auth=True)):
|
|
with patch(ACCESS_MODE, return_value=settings, side_effect=failure):
|
|
with pytest.raises(Forbidden, match="app or access mode not loaded"):
|
|
CheckAppAccess().run(subject, ctx, sqlite_session)
|
|
|
|
def test_refuses_a_private_app_when_the_user_cannot_be_resolved(self, sqlite_session: Session) -> None:
|
|
persist(sqlite_session, make_app())
|
|
subject = account_subject(account_id=None)
|
|
ctx = make_ctx(sqlite_session, subject=subject, app_id=APP_ID)
|
|
|
|
with patch(FEATURES, return_value=system_features(webapp_auth=True)):
|
|
with patch(ACCESS_MODE, return_value=webapp_settings(_requirements_WebAppAccessMode.PRIVATE.value)):
|
|
with pytest.raises(Forbidden, match="cannot resolve user for private app check"):
|
|
CheckAppAccess().run(subject, ctx, sqlite_session)
|
|
|
|
|
|
"""The /openapi/v1 allow/deny matrix, and a snapshot of the generated document.
|
|
|
|
The matrix is derived from what actually runs, not from what a route declares: a
|
|
route's own requirements are merged with the fixed ones its subject's pipeline
|
|
carries, a `CheckWorkspaceRole` applies only where RBAC is off and a `CheckRBACPermission` only where it is on,
|
|
and `CheckAppAccess` applies the private-app check whether
|
|
or not `webapp_auth.enabled` gates the ACL. Reading declarations alone under-counts
|
|
every one of those.
|
|
|
|
Requests run through the real Flask blueprint with the real router, the real
|
|
pipeline and real database rows. The only substitutions are at process seams the
|
|
router already treats as pluggable: the bound `BearerAuthenticator` (so a token's
|
|
subject kind and scope set are chosen per row), the enterprise HTTP clients, and
|
|
the flask-login mount. The RBAC backend and the enterprise web-app service are
|
|
stubbed per row, so those rows pin how *this* code handles each answer they can
|
|
give — never what the real services decide.
|
|
|
|
Cases are never gated on whether a route declares a check. A row that asserts
|
|
`ADMIT` where RBAC, a workspace role check or a licence gate does not fire is pinning the
|
|
check's *absence*: losing a check is caught by the deny rows, and gaining one is
|
|
caught by these. `files.upload` is the route the design says must never acquire an
|
|
RBAC permission, and `files.upload-rbac_on_denied` is the row that says so.
|
|
|
|
Admission is observed as HTTP 418: a `user_logged_in` receiver raises `ImATeapot`
|
|
at the moment the pipeline mounts the caller, which is after every requirement has
|
|
passed and before the view body runs. That keeps a row from depending on whether a
|
|
view could complete (several stream SSE or invoke a workflow). One route mounts no
|
|
caller — `permitted_external.list` resolves no end user because it carries no
|
|
`app_id` — so its admission row says so and asserts the view's own 200 instead.
|
|
|
|
Two answers in the table are worth reading twice, because they are what the code
|
|
does rather than what a route's declaration suggests:
|
|
|
|
* `workspaces.describe` declares no membership requirement, so a non-member is
|
|
*admitted* by auth and refused by the view's own lookup. `workspaces.switch`,
|
|
one path segment away, declares one and is refused by auth.
|
|
* No token the shipped registry mints can fail `CheckScope` — `dfoa_` carries
|
|
`Scope.FULL` and `dfoe_` carries exactly the two scopes its routes ask for — so
|
|
the scope rows mint from a deliberately narrowed registry. Those rows are not
|
|
dead weight and must not be simplified away: `CheckScope` is live code on every
|
|
request, and the day a narrower token kind is minted it is the only thing
|
|
standing between that token and every route it was not scoped for.
|
|
|
|
`Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE` is the one case that combines a real
|
|
membership failure with a real scope failure on the same request. Neither
|
|
`INSUFFICIENT_SCOPE` nor `NON_MEMBER` alone proves which one a doubly-broken request
|
|
hears; membership runs EARLY and scope NORMAL, so it hears membership.
|
|
|
|
Routes that declare the same requirement tuple answer every case the same way,
|
|
so `MATRIX` runs the cases on one representative per tuple and `DECLARED` pins
|
|
which tuple each route carries. A route that changes its tuple fails
|
|
the wiring test; a tuple that changes its answers fails its representative's rows.
|
|
"""
|
|
|
|
|
|
ADMITTED = 418
|
|
|
|
|
|
class Trait(StrEnum):
|
|
"""Structural facts about a route, used only to decide which cases can reach it.
|
|
|
|
Every member here is a fact about the *shape* of the request — is there an
|
|
`app_id` in the path, can a `dfoe_` token address this route at all. None is
|
|
read off a route's declared requirements, deliberately: gating a
|
|
case on a declared check means a route that *gains* that check during migration
|
|
simply loses the row, and gaining a check is the hazard this plan cares about
|
|
most. Cases like `RBAC_ON_DENIED` therefore run on every route an account
|
|
caller can reach, and assert `ADMIT` where the check does not exist today.
|
|
"""
|
|
|
|
APP_SCOPED = auto()
|
|
ACCOUNT_PRIMARY = auto()
|
|
EXTERNAL_REACHABLE = auto()
|
|
ENTERPRISE_ONLY = auto()
|
|
|
|
|
|
class Case(StrEnum):
|
|
NO_BEARER = auto()
|
|
MEMBER = auto()
|
|
WRONG_SUBJECT = auto()
|
|
INSUFFICIENT_SCOPE = auto()
|
|
NON_MEMBER = auto()
|
|
NON_MEMBER_AND_INSUFFICIENT_SCOPE = auto()
|
|
LOW_ROLE = auto()
|
|
APP_API_DISABLED = auto()
|
|
UNKNOWN_APP = auto()
|
|
FOREIGN_WORKSPACE_QUERY = auto()
|
|
EDITION_NOT_ENTERPRISE = auto()
|
|
LICENSE_INVALID = auto()
|
|
EE_LICENSE_INVALID = auto()
|
|
EE_ACCOUNT_PUBLIC = auto()
|
|
EE_ACCOUNT_SSO_VERIFIED = auto()
|
|
EE_ACCOUNT_PRIVATE_ALL = auto()
|
|
EE_ACCOUNT_PRIVATE_PERMITTED = auto()
|
|
EE_ACCOUNT_PRIVATE_REFUSED = auto()
|
|
EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF = auto()
|
|
EE_ACCOUNT_MODE_UNRESOLVED = auto()
|
|
EE_EXTERNAL_PUBLIC = auto()
|
|
EE_EXTERNAL_SSO_VERIFIED = auto()
|
|
EE_EXTERNAL_PRIVATE_ALL = auto()
|
|
EE_EXTERNAL_PRIVATE = auto()
|
|
EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF = auto()
|
|
EE_EXTERNAL_MODE_UNRESOLVED = auto()
|
|
RBAC_ON_LOW_ROLE = auto()
|
|
RBAC_ON_DENIED = auto()
|
|
|
|
|
|
class Bearer(StrEnum):
|
|
NONE = auto()
|
|
ACCOUNT_MEMBER = auto()
|
|
ACCOUNT_LOW_ROLE = auto()
|
|
ACCOUNT_OUTSIDER = auto()
|
|
EXTERNAL = auto()
|
|
OTHER_SUBJECT = auto()
|
|
PRIMARY = auto()
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Route:
|
|
id: str
|
|
method: str
|
|
path: str
|
|
traits: frozenset[Trait]
|
|
query: str = ""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Expect:
|
|
"""`message` is the canonical ErrorBody message; `None` means it is not pinned."""
|
|
|
|
status: int
|
|
message: str | None = None
|
|
note: str = ""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Scenario:
|
|
"""The world a case runs in. `edition=None` means the route's native edition."""
|
|
|
|
bearer: Bearer
|
|
edition: DeploymentEdition | None = None
|
|
narrow_scopes: bool = False
|
|
app_api_enabled: bool = True
|
|
unknown_app: bool = False
|
|
foreign_workspace_query: bool = False
|
|
webapp_auth: bool = False
|
|
access_mode: _auth_matrix_WebAppAccessMode | None = _auth_matrix_WebAppAccessMode.PUBLIC
|
|
private_app_permitted: bool = True
|
|
license_status: LicenseStatus = LicenseStatus.ACTIVE
|
|
rbac_enabled: bool = False
|
|
rbac_allows: bool = True
|
|
|
|
|
|
ADMIT = Expect(ADMITTED)
|
|
DENY_NO_BEARER = Expect(401, "bearer required")
|
|
DENY_WRONG_SUBJECT = Expect(403, "unsupported_token_type")
|
|
DENY_SSO_NEEDS_EE = Expect(403, "external_sso_requires_ee")
|
|
DENY_SCOPE = Expect(403, "insufficient_scope")
|
|
DENY_NON_MEMBER = Expect(404, "workspace not found")
|
|
DENY_ROLE = Expect(403, "insufficient workspace role")
|
|
DENY_API_DISABLED = Expect(403, ServiceApiDisabled.description)
|
|
DENY_UNKNOWN_APP = Expect(404, "app not found")
|
|
DENY_ACCESS_MODE = Expect(403, "subject_not_allowed_for_access_mode")
|
|
DENY_MODE_UNRESOLVED = Expect(403, "app or access mode not loaded")
|
|
DENY_PRIVATE_APP = Expect(403, "user_not_allowed_for_private_app")
|
|
DENY_EDITION = Expect(404, note="endpoint-level edition gate, raised before the bearer is read")
|
|
DENY_LICENSE = Expect(403, "license_invalid")
|
|
DENY_RBAC = Expect(403, note="bare werkzeug Forbidden from enforce_rbac_checks")
|
|
ADMIT_NO_RBAC_PERMISSION = Expect(
|
|
ADMITTED,
|
|
note=(
|
|
"pins a check this route does NOT have: RBAC is on and denying, and the route still "
|
|
"admits because it declares no scene. Attaching one during migration turns this 403. "
|
|
"files.upload is the route the design says must never gain a scene"
|
|
),
|
|
)
|
|
ADMIT_NO_WORKSPACE_ROLE = Expect(
|
|
ADMITTED,
|
|
note="pins a check this route does NOT have: RBAC is on and a NORMAL member is still admitted",
|
|
)
|
|
ADMIT_NO_LICENCE_GATE = Expect(
|
|
ADMITTED,
|
|
note="pins a check this route does NOT have: the licence is dead and the route still admits",
|
|
)
|
|
ADMIT_NO_MOUNT = Expect(
|
|
200,
|
|
note="external subject mounts no caller on an app-less route, so admission shows as the view's own 200",
|
|
)
|
|
|
|
|
|
_RUN_TRAITS = frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE})
|
|
|
|
ROUTES: tuple[Route, ...] = (
|
|
Route(
|
|
"knowledge_fs.ls",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:ls",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_fs.tree",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:tree",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_fs.grep",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:grep",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_fs.find",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:find",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_fs.diff",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:diff",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_fs.cat",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:cat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_fs.stat",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:stat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route("describe.account", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route("account.sessions.revoke_self", "DELETE", "/account/sessions/self", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route("get.account.session", "GET", "/account/sessions", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route(
|
|
"account.sessions.revoke_one",
|
|
"DELETE",
|
|
"/account/sessions/{session_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"apps.describe",
|
|
"GET",
|
|
"/apps/{app_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route("apps.list", "GET", "/apps", frozenset({Trait.ACCOUNT_PRIMARY}), query="workspace_id={workspace_id}"),
|
|
Route("workspaces.list", "GET", "/workspaces", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route("workspaces.describe", "GET", "/workspaces/{workspace_id}", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route("workspaces.members.list", "GET", "/workspaces/{workspace_id}/members", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route(
|
|
"workspaces.members.invite",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/members",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"workspaces.members.remove",
|
|
"DELETE",
|
|
"/workspaces/{workspace_id}/members/{member_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"workspaces.members.update_role",
|
|
"PATCH",
|
|
"/workspaces/{workspace_id}/members/{member_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"plugins.marketplace",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/marketplace/plugins",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route("plugins.list", "GET", "/workspaces/{workspace_id}/plugins", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route("plugins.install", "POST", "/workspaces/{workspace_id}/plugins:install", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route(
|
|
"plugins.task",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/plugin-tasks/{task_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"plugins.delete",
|
|
"DELETE",
|
|
"/workspaces/{workspace_id}/plugins/{plugin_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"model_providers.describe",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/model-providers/{provider}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"model_providers.credential.create",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/model-providers/{provider}/credentials",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"model_providers.credential.set",
|
|
"PATCH",
|
|
"/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route("tools.list", "GET", "/workspaces/{workspace_id}/tools", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route(
|
|
"tool_providers.describe",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/tool-providers/{provider}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"tool_providers.credential.create",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/tool-providers/{provider}/credentials",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"tool_providers.credential.set",
|
|
"PATCH",
|
|
"/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route("skills.list", "GET", "/workspaces/{workspace_id}/skills", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route("skills.describe", "GET", "/workspaces/{workspace_id}/skills/{skill_id}", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route(
|
|
"skills.versions",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/skills/{skill_id}/versions",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"skills.export",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/skills/{skill_id}/archive",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"skills.publish",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/skills/{skill_id}:publish",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"knowledge_bases.list",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/knowledge-bases",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"models.list",
|
|
"GET",
|
|
"/workspaces/{workspace_id}/models",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"models.credential.create",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/model-providers/{provider}/models/credentials",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"models.credential.set",
|
|
"PATCH",
|
|
"/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route("skills.import", "POST", "/workspaces/{workspace_id}/skills/imports", frozenset({Trait.ACCOUNT_PRIMARY})),
|
|
Route(
|
|
"app_dsl.import",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/apps/imports",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"app_dsl.check",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/apps/imports:check",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"app_dsl.import_confirm",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/apps/imports/{import_id}:confirm",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"app_dsl.export",
|
|
"GET",
|
|
"/apps/{app_id}/dsl",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_dsl.check_dependencies",
|
|
"GET",
|
|
"/apps/{app_id}/dependencies:check",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route("app_run.workflow", "POST", "/apps/{app_id}/workflow:run", _RUN_TRAITS),
|
|
Route("app_run.chat", "POST", "/apps/{app_id}/chat:run", _RUN_TRAITS),
|
|
Route("app_run.advanced_chat", "POST", "/apps/{app_id}/advanced-chat:run", _RUN_TRAITS),
|
|
Route("app_run.completion", "POST", "/apps/{app_id}/completion:run", _RUN_TRAITS),
|
|
Route(
|
|
"app_run.draft.workflow",
|
|
"POST",
|
|
"/apps/{app_id}/draft/workflow:run",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_run.draft.advanced_chat",
|
|
"POST",
|
|
"/apps/{app_id}/draft/advanced-chat:run",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route("app_workflow.run.list", "GET", "/apps/{app_id}/runs", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})),
|
|
Route(
|
|
"app_workflow.run.describe",
|
|
"GET",
|
|
"/apps/{app_id}/runs/{run_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_workflow.run.nodes",
|
|
"GET",
|
|
"/apps/{app_id}/runs/{run_id}/nodes",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_workflow.publish", "POST", "/apps/{app_id}:publish", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})
|
|
),
|
|
Route(
|
|
"app_workflow.version.list",
|
|
"GET",
|
|
"/apps/{app_id}/versions",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_workflow.version.restore",
|
|
"POST",
|
|
"/apps/{app_id}/versions/{version_id}:restore",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_workflow.release_check",
|
|
"GET",
|
|
"/apps/{app_id}/release:check",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route("app_workflow.env.list", "GET", "/apps/{app_id}/env", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})),
|
|
Route(
|
|
"app_workflow.env.set",
|
|
"PUT",
|
|
"/apps/{app_id}/env/{env_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_workflow.env.delete",
|
|
"DELETE",
|
|
"/apps/{app_id}/env/{env_id}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route("node_types.list", "GET", "/node-types", frozenset({Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE})),
|
|
Route(
|
|
"node_types.describe",
|
|
"GET",
|
|
"/node-types/{node_type}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"app_create.workflow", "POST", "/workspaces/{workspace_id}/apps/workflow", frozenset({Trait.ACCOUNT_PRIMARY})
|
|
),
|
|
Route(
|
|
"app_create.advanced_chat",
|
|
"POST",
|
|
"/workspaces/{workspace_id}/apps/advanced-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
),
|
|
Route(
|
|
"app_workflow.node_run.workflow",
|
|
"POST",
|
|
"/apps/{app_id}/draft/workflow/nodes/{node_id}:run",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_workflow.node_run.advanced_chat",
|
|
"POST",
|
|
"/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.describe.workflow",
|
|
"GET",
|
|
"/apps/{app_id}/app-info/workflow",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.set.workflow",
|
|
"PATCH",
|
|
"/apps/{app_id}/app-info/workflow",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"service_api.describe",
|
|
"GET",
|
|
"/apps/{app_id}/service-api",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"service_api.set",
|
|
"PATCH",
|
|
"/apps/{app_id}/service-api",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.describe.advanced_chat",
|
|
"GET",
|
|
"/apps/{app_id}/app-info/advanced-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.set.advanced_chat",
|
|
"PATCH",
|
|
"/apps/{app_id}/app-info/advanced-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.describe.chat",
|
|
"GET",
|
|
"/apps/{app_id}/app-info/chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.set.chat",
|
|
"PATCH",
|
|
"/apps/{app_id}/app-info/chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.describe.agent_chat",
|
|
"GET",
|
|
"/apps/{app_id}/app-info/agent-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.set.agent_chat",
|
|
"PATCH",
|
|
"/apps/{app_id}/app-info/agent-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.describe.completion",
|
|
"GET",
|
|
"/apps/{app_id}/app-info/completion",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.set.completion",
|
|
"PATCH",
|
|
"/apps/{app_id}/app-info/completion",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.describe.agent",
|
|
"GET",
|
|
"/apps/{app_id}/app-info/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_info.set.agent",
|
|
"PATCH",
|
|
"/apps/{app_id}/app-info/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"service_api.describe.agent",
|
|
"GET",
|
|
"/apps/{app_id}/service-api/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"service_api.set.agent",
|
|
"PATCH",
|
|
"/apps/{app_id}/service-api/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.describe.workflow",
|
|
"GET",
|
|
"/apps/{app_id}/webapp/workflow",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.set.workflow",
|
|
"PATCH",
|
|
"/apps/{app_id}/webapp/workflow",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.reset",
|
|
"POST",
|
|
"/apps/{app_id}/webapp:reset",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp_access.describe",
|
|
"GET",
|
|
"/apps/{app_id}/webapp-access",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp_access.set",
|
|
"PUT",
|
|
"/apps/{app_id}/webapp-access",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.describe.advanced_chat",
|
|
"GET",
|
|
"/apps/{app_id}/webapp/advanced-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.set.advanced_chat",
|
|
"PATCH",
|
|
"/apps/{app_id}/webapp/advanced-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.describe.chat",
|
|
"GET",
|
|
"/apps/{app_id}/webapp/chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.set.chat",
|
|
"PATCH",
|
|
"/apps/{app_id}/webapp/chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.describe.agent_chat",
|
|
"GET",
|
|
"/apps/{app_id}/webapp/agent-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.set.agent_chat",
|
|
"PATCH",
|
|
"/apps/{app_id}/webapp/agent-chat",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.describe.completion",
|
|
"GET",
|
|
"/apps/{app_id}/webapp/completion",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.set.completion",
|
|
"PATCH",
|
|
"/apps/{app_id}/webapp/completion",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.describe.agent",
|
|
"GET",
|
|
"/apps/{app_id}/webapp/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.set.agent",
|
|
"PATCH",
|
|
"/apps/{app_id}/webapp/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp.reset.agent",
|
|
"POST",
|
|
"/apps/{app_id}/webapp/agent:reset",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp_access.describe.agent",
|
|
"GET",
|
|
"/apps/{app_id}/webapp-access/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"webapp_access.set.agent",
|
|
"PUT",
|
|
"/apps/{app_id}/webapp-access/agent",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
|
),
|
|
Route(
|
|
"app_run.stop",
|
|
"POST",
|
|
"/apps/{app_id}/runs/{run_id}:stop",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"app_run.stop.task",
|
|
"POST",
|
|
"/apps/{app_id}/tasks/{task_id}:stop",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"files.upload",
|
|
"POST",
|
|
"/apps/{app_id}/files",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"human_input_form.get",
|
|
"GET",
|
|
"/apps/{app_id}/human-input-forms/{form_token}",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"human_input_form.submit",
|
|
"POST",
|
|
"/apps/{app_id}/human-input-forms/{form_token}:submit",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"workflow_events.stream",
|
|
"GET",
|
|
"/apps/{app_id}/runs/{run_id}/events",
|
|
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
),
|
|
Route(
|
|
"permitted_external.list",
|
|
"GET",
|
|
"/permitted-external-apps",
|
|
frozenset({Trait.EXTERNAL_REACHABLE, Trait.ENTERPRISE_ONLY}),
|
|
),
|
|
Route(
|
|
"permitted_external.describe",
|
|
"GET",
|
|
"/permitted-external-apps/{app_id}",
|
|
frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE, Trait.ENTERPRISE_ONLY}),
|
|
),
|
|
)
|
|
|
|
|
|
CASE_REQUIRES: dict[Case, frozenset[Trait]] = {
|
|
Case.NO_BEARER: frozenset(),
|
|
Case.MEMBER: frozenset(),
|
|
Case.WRONG_SUBJECT: frozenset(),
|
|
Case.INSUFFICIENT_SCOPE: frozenset(),
|
|
Case.NON_MEMBER: frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
# frozenset() rather than {ACCOUNT_PRIMARY}: this one also has to reach the
|
|
# SSO-only routes, to pin that they answer on the subject axis, not this one.
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: frozenset(),
|
|
Case.LOW_ROLE: frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
Case.APP_API_DISABLED: frozenset({Trait.APP_SCOPED}),
|
|
Case.UNKNOWN_APP: frozenset({Trait.APP_SCOPED}),
|
|
Case.FOREIGN_WORKSPACE_QUERY: frozenset({Trait.APP_SCOPED}),
|
|
Case.EDITION_NOT_ENTERPRISE: frozenset({Trait.ENTERPRISE_ONLY}),
|
|
Case.LICENSE_INVALID: frozenset(),
|
|
Case.EE_LICENSE_INVALID: frozenset(),
|
|
Case.EE_ACCOUNT_PUBLIC: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_ACCOUNT_SSO_VERIFIED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_ACCOUNT_PRIVATE_ALL: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_ACCOUNT_PRIVATE_PERMITTED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_ACCOUNT_MODE_UNRESOLVED: frozenset({Trait.APP_SCOPED, Trait.ACCOUNT_PRIMARY}),
|
|
Case.EE_EXTERNAL_PUBLIC: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
Case.EE_EXTERNAL_SSO_VERIFIED: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
Case.EE_EXTERNAL_PRIVATE_ALL: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
Case.EE_EXTERNAL_PRIVATE: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
Case.EE_EXTERNAL_MODE_UNRESOLVED: frozenset({Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE}),
|
|
Case.RBAC_ON_LOW_ROLE: frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
Case.RBAC_ON_DENIED: frozenset({Trait.ACCOUNT_PRIMARY}),
|
|
}
|
|
|
|
|
|
SCENARIOS: dict[Case, Scenario] = {
|
|
Case.NO_BEARER: Scenario(bearer=Bearer.NONE),
|
|
Case.MEMBER: Scenario(bearer=Bearer.PRIMARY),
|
|
Case.WRONG_SUBJECT: Scenario(bearer=Bearer.OTHER_SUBJECT),
|
|
Case.INSUFFICIENT_SCOPE: Scenario(bearer=Bearer.PRIMARY, narrow_scopes=True),
|
|
Case.NON_MEMBER: Scenario(bearer=Bearer.ACCOUNT_OUTSIDER),
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: Scenario(bearer=Bearer.ACCOUNT_OUTSIDER, narrow_scopes=True),
|
|
Case.LOW_ROLE: Scenario(bearer=Bearer.ACCOUNT_LOW_ROLE),
|
|
Case.APP_API_DISABLED: Scenario(bearer=Bearer.PRIMARY, app_api_enabled=False),
|
|
Case.UNKNOWN_APP: Scenario(bearer=Bearer.PRIMARY, unknown_app=True),
|
|
Case.FOREIGN_WORKSPACE_QUERY: Scenario(bearer=Bearer.PRIMARY, foreign_workspace_query=True),
|
|
Case.EDITION_NOT_ENTERPRISE: Scenario(bearer=Bearer.PRIMARY, edition=DeploymentEdition.COMMUNITY),
|
|
Case.LICENSE_INVALID: Scenario(bearer=Bearer.PRIMARY, license_status=LicenseStatus.EXPIRED),
|
|
Case.EE_LICENSE_INVALID: Scenario(
|
|
bearer=Bearer.PRIMARY, edition=DeploymentEdition.ENTERPRISE, license_status=LicenseStatus.EXPIRED
|
|
),
|
|
Case.EE_ACCOUNT_PUBLIC: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PUBLIC,
|
|
),
|
|
Case.EE_ACCOUNT_SSO_VERIFIED: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.SSO_VERIFIED,
|
|
),
|
|
Case.EE_ACCOUNT_PRIVATE_ALL: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE_ALL,
|
|
),
|
|
Case.EE_ACCOUNT_PRIVATE_PERMITTED: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE,
|
|
private_app_permitted=True,
|
|
),
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE,
|
|
private_app_permitted=False,
|
|
),
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=False,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE,
|
|
private_app_permitted=False,
|
|
),
|
|
Case.EE_ACCOUNT_MODE_UNRESOLVED: Scenario(
|
|
bearer=Bearer.ACCOUNT_MEMBER,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=None,
|
|
),
|
|
Case.EE_EXTERNAL_PUBLIC: Scenario(
|
|
bearer=Bearer.EXTERNAL,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PUBLIC,
|
|
),
|
|
Case.EE_EXTERNAL_SSO_VERIFIED: Scenario(
|
|
bearer=Bearer.EXTERNAL,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.SSO_VERIFIED,
|
|
),
|
|
Case.EE_EXTERNAL_PRIVATE_ALL: Scenario(
|
|
bearer=Bearer.EXTERNAL,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE_ALL,
|
|
),
|
|
Case.EE_EXTERNAL_PRIVATE: Scenario(
|
|
bearer=Bearer.EXTERNAL,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE,
|
|
),
|
|
Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: Scenario(
|
|
bearer=Bearer.EXTERNAL,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=False,
|
|
access_mode=_auth_matrix_WebAppAccessMode.PRIVATE,
|
|
private_app_permitted=False,
|
|
),
|
|
Case.EE_EXTERNAL_MODE_UNRESOLVED: Scenario(
|
|
bearer=Bearer.EXTERNAL,
|
|
edition=DeploymentEdition.ENTERPRISE,
|
|
webapp_auth=True,
|
|
access_mode=None,
|
|
),
|
|
Case.RBAC_ON_LOW_ROLE: Scenario(bearer=Bearer.ACCOUNT_LOW_ROLE, rbac_enabled=True, rbac_allows=True),
|
|
Case.RBAC_ON_DENIED: Scenario(bearer=Bearer.ACCOUNT_MEMBER, rbac_enabled=True, rbac_allows=False),
|
|
}
|
|
|
|
|
|
ROUTER_CASE_ROUTE = "describe.account"
|
|
ROUTER_CASES: dict[Case, Expect] = {
|
|
Case.NO_BEARER: DENY_NO_BEARER,
|
|
Case.LICENSE_INVALID: ADMIT_NO_LICENCE_GATE,
|
|
Case.EE_LICENSE_INVALID: DENY_LICENSE,
|
|
}
|
|
"""Answered by the router before any route-specific requirement runs, so pinned once."""
|
|
|
|
|
|
_ACCOUNT_ONLY_NO_WORKSPACE: dict[Case, Expect] = {
|
|
Case.MEMBER: ADMIT,
|
|
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.NON_MEMBER: ADMIT,
|
|
# No live membership check on this route, so the outsider's only failure is
|
|
# scope — same answer pre- and post-PR.
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.LOW_ROLE: ADMIT,
|
|
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
|
Case.RBAC_ON_DENIED: ADMIT_NO_RBAC_PERMISSION,
|
|
}
|
|
|
|
_ACCOUNT_MEMBER_NO_ROLE: dict[Case, Expect] = {
|
|
Case.MEMBER: ADMIT,
|
|
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.NON_MEMBER: DENY_NON_MEMBER,
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
|
Case.LOW_ROLE: ADMIT,
|
|
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
|
Case.RBAC_ON_DENIED: ADMIT_NO_RBAC_PERMISSION,
|
|
}
|
|
|
|
_ACCOUNT_MEMBER_WITH_ROLE: dict[Case, Expect] = {
|
|
Case.MEMBER: ADMIT,
|
|
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.NON_MEMBER: DENY_NON_MEMBER,
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
|
Case.LOW_ROLE: DENY_ROLE,
|
|
Case.RBAC_ON_LOW_ROLE: ADMIT,
|
|
Case.RBAC_ON_DENIED: DENY_RBAC,
|
|
}
|
|
|
|
_DUAL_SUBJECT_RUN: dict[Case, Expect] = {
|
|
Case.MEMBER: ADMIT,
|
|
Case.WRONG_SUBJECT: DENY_SSO_NEEDS_EE,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.NON_MEMBER: DENY_NON_MEMBER,
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
|
Case.LOW_ROLE: ADMIT,
|
|
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
|
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
|
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
|
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
|
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED: DENY_PRIVATE_APP,
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_PRIVATE_APP,
|
|
Case.EE_ACCOUNT_MODE_UNRESOLVED: DENY_MODE_UNRESOLVED,
|
|
Case.EE_EXTERNAL_PUBLIC: ADMIT,
|
|
Case.EE_EXTERNAL_SSO_VERIFIED: ADMIT,
|
|
Case.EE_EXTERNAL_PRIVATE_ALL: DENY_ACCESS_MODE,
|
|
Case.EE_EXTERNAL_PRIVATE: DENY_ACCESS_MODE,
|
|
Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_PRIVATE_APP,
|
|
Case.EE_EXTERNAL_MODE_UNRESOLVED: DENY_MODE_UNRESOLVED,
|
|
Case.RBAC_ON_DENIED: DENY_RBAC,
|
|
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
|
}
|
|
|
|
|
|
_ACCOUNT_READER_APP: dict[Case, Expect] = {
|
|
Case.MEMBER: ADMIT,
|
|
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.NON_MEMBER: DENY_NON_MEMBER,
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
|
Case.LOW_ROLE: ADMIT,
|
|
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
|
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
|
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
|
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
|
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
|
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
|
Case.RBAC_ON_DENIED: DENY_RBAC,
|
|
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
|
}
|
|
|
|
_ACCOUNT_EDITOR_APP: dict[Case, Expect] = {
|
|
**_ACCOUNT_READER_APP,
|
|
Case.LOW_ROLE: DENY_ROLE,
|
|
Case.RBAC_ON_LOW_ROLE: ADMIT,
|
|
}
|
|
|
|
_ACCOUNT_READER_SETTINGS: dict[Case, Expect] = {**_ACCOUNT_READER_APP, Case.APP_API_DISABLED: ADMIT}
|
|
_ACCOUNT_EDITOR_SETTINGS: dict[Case, Expect] = {**_ACCOUNT_EDITOR_APP, Case.APP_API_DISABLED: ADMIT}
|
|
|
|
DENY_NEEDS_WEBAPP_EE = Expect(403, WebAppAccessRequiresEE.description)
|
|
|
|
_WEBAPP_ACCESS_EDITOR: dict[Case, Expect] = {
|
|
**_ACCOUNT_EDITOR_SETTINGS,
|
|
Case.MEMBER: DENY_NEEDS_WEBAPP_EE,
|
|
Case.APP_API_DISABLED: DENY_NEEDS_WEBAPP_EE,
|
|
Case.FOREIGN_WORKSPACE_QUERY: DENY_NEEDS_WEBAPP_EE,
|
|
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_NEEDS_WEBAPP_EE,
|
|
Case.RBAC_ON_LOW_ROLE: DENY_NEEDS_WEBAPP_EE,
|
|
}
|
|
|
|
|
|
_ANY_BEARER: dict[Case, Expect] = {
|
|
**{case: ADMIT for case in Case if CASE_REQUIRES[case] <= {Trait.ACCOUNT_PRIMARY, Trait.EXTERNAL_REACHABLE}},
|
|
Case.WRONG_SUBJECT: DENY_SSO_NEEDS_EE,
|
|
}
|
|
"""A route with no requirements admits every case that carries a valid bearer. The one
|
|
exception is the router's own answer: an external-SSO bearer is refused outside enterprise."""
|
|
|
|
MATRIX: dict[str, dict[Case, Expect]] = {
|
|
"describe.account": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
|
"workspaces.list": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
|
"apps.list": dict(_ACCOUNT_MEMBER_NO_ROLE),
|
|
"workspaces.switch": dict(_ACCOUNT_MEMBER_NO_ROLE),
|
|
# invite/remove check WORKSPACE_MEMBER_MANAGE; update_role checks WORKSPACE_ROLE_MANAGE
|
|
# (matches check_member_permission's own add/remove vs update split)
|
|
"workspaces.members.invite": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"workspaces.members.update_role": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"plugins.install": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC},
|
|
"skills.list": {**_ACCOUNT_MEMBER_NO_ROLE, Case.RBAC_ON_DENIED: DENY_RBAC},
|
|
"skills.publish": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"skills.import": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"model_providers.credential.create": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"app_dsl.check": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"app_create.workflow": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
|
"node_types.list": dict(_ANY_BEARER),
|
|
"app_workflow.node_run.workflow": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_info.describe.workflow": dict(_ACCOUNT_READER_SETTINGS),
|
|
"app_info.set.workflow": dict(_ACCOUNT_EDITOR_SETTINGS),
|
|
"service_api.describe": dict(_ACCOUNT_EDITOR_SETTINGS),
|
|
"service_api.set": dict(_ACCOUNT_EDITOR_SETTINGS),
|
|
"webapp.describe.workflow": dict(_ACCOUNT_READER_SETTINGS),
|
|
"webapp.set.workflow": dict(_ACCOUNT_EDITOR_SETTINGS),
|
|
"webapp.reset": dict(_ACCOUNT_EDITOR_SETTINGS),
|
|
"webapp_access.describe": dict(_WEBAPP_ACCESS_EDITOR),
|
|
"apps.describe": dict(_ACCOUNT_READER_APP),
|
|
"app_dsl.export": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_run.draft.workflow": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.run.list": dict(_ACCOUNT_READER_APP),
|
|
"app_workflow.run.describe": dict(_ACCOUNT_READER_APP),
|
|
"app_workflow.run.nodes": dict(_ACCOUNT_READER_APP),
|
|
"app_workflow.publish": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.version.list": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.version.restore": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.release_check": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.env.list": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.env.set": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_workflow.env.delete": dict(_ACCOUNT_EDITOR_APP),
|
|
"app_run.workflow": dict(_DUAL_SUBJECT_RUN),
|
|
"app_run.chat": dict(_DUAL_SUBJECT_RUN),
|
|
"app_run.advanced_chat": dict(_DUAL_SUBJECT_RUN),
|
|
"app_run.completion": dict(_DUAL_SUBJECT_RUN),
|
|
"human_input_form.get": dict(_DUAL_SUBJECT_RUN),
|
|
"files.upload": {
|
|
**_DUAL_SUBJECT_RUN,
|
|
Case.RBAC_ON_DENIED: ADMIT_NO_RBAC_PERMISSION,
|
|
},
|
|
"permitted_external.list": {
|
|
Case.MEMBER: ADMIT_NO_MOUNT,
|
|
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
# An account bearer answers on the subject axis here, before scope or
|
|
# membership: this route's CheckSubject allows only ExternalSsoSubject.
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_WRONG_SUBJECT,
|
|
Case.EDITION_NOT_ENTERPRISE: DENY_EDITION,
|
|
},
|
|
"permitted_external.describe": {
|
|
Case.MEMBER: ADMIT,
|
|
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
|
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
|
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_WRONG_SUBJECT,
|
|
Case.EDITION_NOT_ENTERPRISE: DENY_EDITION,
|
|
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
|
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
|
Case.FOREIGN_WORKSPACE_QUERY: Expect(
|
|
ADMITTED,
|
|
note="a foreign ?workspace_id= is ignored on every app-scoped route (spec 2.9)",
|
|
),
|
|
Case.EE_EXTERNAL_PUBLIC: ADMIT,
|
|
Case.EE_EXTERNAL_SSO_VERIFIED: ADMIT,
|
|
Case.EE_EXTERNAL_PRIVATE_ALL: DENY_ACCESS_MODE,
|
|
Case.EE_EXTERNAL_PRIVATE: DENY_ACCESS_MODE,
|
|
Case.EE_EXTERNAL_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: DENY_PRIVATE_APP,
|
|
Case.EE_EXTERNAL_MODE_UNRESOLVED: DENY_MODE_UNRESOLVED,
|
|
},
|
|
}
|
|
"""One representative per distinct declared requirement tuple; `DECLARED` pins the rest."""
|
|
|
|
|
|
_ACCOUNT = (AccountSubject,)
|
|
_ACCOUNT_OR_EXTERNAL = (AccountSubject, ExternalSsoSubject)
|
|
_OWNER_ADMIN = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
|
_EDITOR_UP = frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
|
|
|
|
_REQ_ACCOUNT_FULL = (CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.FULL))
|
|
_REQ_ACCOUNT_WORKSPACE_READ = (CheckSubject(allowed=_ACCOUNT), CheckScope(Scope.WORKSPACE_READ))
|
|
_REQ_ACCOUNT_APPS_READ_MEMBER = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.APPS_READ),
|
|
CheckWorkspaceMember(),
|
|
)
|
|
_REQ_ACCOUNT_WORKSPACE_READ_MEMBER = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_READ),
|
|
CheckWorkspaceMember(),
|
|
)
|
|
_REQ_SKILL_READ = (
|
|
CheckSkillEnabled(),
|
|
*_REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
CheckRBACPermission(RBACCheck(RBACPermission.SKILL_VIEW, Workspace())),
|
|
)
|
|
_REQ_MEMBER_MANAGE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_MEMBER_MANAGE, Workspace())),
|
|
CheckWorkspaceRole(_OWNER_ADMIN),
|
|
)
|
|
_REQ_ROLE_MANAGE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace())),
|
|
CheckWorkspaceRole(_OWNER_ADMIN),
|
|
)
|
|
_REQ_DSL_WORKSPACE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
_REQ_DSL_APP = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckScope(Scope.APPS_READ),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
_REQ_APP_DESCRIBE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckScope(Scope.APPS_READ),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())),
|
|
)
|
|
_REQ_RUN = (
|
|
CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckScope(Scope.APPS_RUN),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())),
|
|
CheckAppAccess(),
|
|
)
|
|
_REQ_RUN_FORM = (*_REQ_RUN, CheckFormSurface())
|
|
_REQ_RELEASE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
_REQ_VERSION_READ = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT),
|
|
CheckScope(Scope.APPS_READ),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
_REQ_ENV_WRITE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_EDIT, PlainApp())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
_REQ_RUN_HISTORY = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckAppMode(_auth_matrix_AppMode.WORKFLOW, _auth_matrix_AppMode.ADVANCED_CHAT),
|
|
CheckScope(Scope.APPS_READ),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())),
|
|
)
|
|
_REQ_DRAFT_RUN = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckScope(Scope.APPS_RUN),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
|
|
|
|
def _draft_req(mode: _auth_matrix_AppMode) -> tuple[Requirement, ...]:
|
|
return (*_REQ_DRAFT_RUN[:3], CheckAppMode(mode), *_REQ_DRAFT_RUN[3:])
|
|
|
|
|
|
_REQ_NODE_TYPES: tuple[Requirement, ...] = ()
|
|
_REQ_APP_CREATE = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, Workspace())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
CheckAppQuota(),
|
|
)
|
|
_REQ_FILES = (
|
|
CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL),
|
|
CheckAppApiEnabled(),
|
|
CheckWorkspaceMember(),
|
|
CheckScope(Scope.APPS_RUN),
|
|
CheckAppAccess(),
|
|
)
|
|
_REQ_EXTERNAL_LIST = (
|
|
CheckSubject(allowed=(ExternalSsoSubject,)),
|
|
CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL),
|
|
)
|
|
_REQ_EXTERNAL_DESCRIBE = (
|
|
CheckSubject(allowed=(ExternalSsoSubject,)),
|
|
CheckAppApiEnabled(),
|
|
CheckScope(Scope.APPS_READ_PERMITTED_EXTERNAL),
|
|
CheckAppAccess(),
|
|
)
|
|
|
|
|
|
def _req_plugin_write(permission: RBACPermission) -> tuple[Requirement, ...]:
|
|
return (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(permission, Workspace())),
|
|
CheckPluginInstallSetting(),
|
|
)
|
|
|
|
|
|
def _req_admin_write(permission: RBACPermission) -> tuple[Requirement, ...]:
|
|
return (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(permission, Workspace())),
|
|
CheckWorkspaceRole(_OWNER_ADMIN),
|
|
)
|
|
|
|
|
|
def _req_skill_write(permission: RBACPermission) -> tuple[Requirement, ...]:
|
|
return (
|
|
CheckSkillEnabled(),
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_WRITE),
|
|
CheckWorkspaceMember(),
|
|
CheckRBACPermission(RBACCheck(permission, Workspace())),
|
|
CheckWorkspaceRole(_EDITOR_UP),
|
|
)
|
|
|
|
|
|
_REQ_PLUGIN_TASK = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckScope(Scope.WORKSPACE_READ),
|
|
CheckWorkspaceMember(),
|
|
CheckPluginInstallSetting(),
|
|
)
|
|
|
|
|
|
def _settings_req(
|
|
modes: tuple[_auth_matrix_AppMode, ...],
|
|
perm: RBACPermission,
|
|
locator: PlainApp | AgentBehindApp,
|
|
scope: Scope,
|
|
roles: frozenset[TenantAccountRole] | None,
|
|
) -> tuple[Requirement, ...]:
|
|
base: tuple[Requirement, ...] = (
|
|
CheckSubject(allowed=_ACCOUNT),
|
|
CheckWorkspaceMember(),
|
|
CheckAppMode(*modes),
|
|
CheckScope(scope),
|
|
CheckRBACPermission(RBACCheck(perm, locator)),
|
|
)
|
|
return (*base, CheckWorkspaceRole(roles)) if roles is not None else base
|
|
|
|
|
|
_RESOURCE_RUN = (CheckSubject(allowed=(*_ACCOUNT_OR_EXTERNAL, ResourceAccessSubject)), *_REQ_RUN[1:])
|
|
|
|
DECLARED: dict[str, tuple[Requirement, ...]] = {
|
|
"knowledge_fs.ls": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"knowledge_fs.tree": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"knowledge_fs.grep": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"knowledge_fs.find": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"knowledge_fs.diff": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"knowledge_fs.cat": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"knowledge_fs.stat": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"describe.account": _REQ_ACCOUNT_FULL,
|
|
"account.sessions.revoke_self": _REQ_ACCOUNT_FULL,
|
|
"get.account.session": _REQ_ACCOUNT_FULL,
|
|
"account.sessions.revoke_one": _REQ_ACCOUNT_FULL,
|
|
"apps.describe": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_APP_DESCRIBE[1:]),
|
|
"apps.list": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_ACCOUNT_APPS_READ_MEMBER[1:]),
|
|
"workspaces.list": (
|
|
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
|
|
*_REQ_ACCOUNT_WORKSPACE_READ[1:],
|
|
),
|
|
"workspaces.describe": _REQ_ACCOUNT_WORKSPACE_READ,
|
|
"workspaces.switch": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"workspaces.members.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"workspaces.members.invite": (*_REQ_MEMBER_MANAGE, CheckWorkspaceInvitationQuota()),
|
|
"workspaces.members.remove": _REQ_MEMBER_MANAGE,
|
|
"workspaces.members.update_role": _REQ_ROLE_MANAGE,
|
|
"plugins.marketplace": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"plugins.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"plugins.install": _req_plugin_write(RBACPermission.PLUGIN_INSTALL),
|
|
"plugins.task": _REQ_PLUGIN_TASK,
|
|
"plugins.delete": _req_plugin_write(RBACPermission.PLUGIN_DELETE),
|
|
"model_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"model_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE),
|
|
"model_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE),
|
|
"tools.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"tool_providers.describe": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"tool_providers.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE),
|
|
"tool_providers.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE),
|
|
"skills.list": _REQ_SKILL_READ,
|
|
"skills.describe": _REQ_SKILL_READ,
|
|
"skills.versions": _REQ_SKILL_READ,
|
|
"skills.export": _REQ_SKILL_READ,
|
|
"skills.publish": _req_skill_write(RBACPermission.SKILL_PUBLISH),
|
|
"skills.import": (
|
|
*_req_skill_write(RBACPermission.SKILL_EDIT),
|
|
CapRequestBody(max_bytes=lambda: 0, max_parts=lambda: 0),
|
|
),
|
|
"knowledge_bases.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"models.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
|
|
"models.credential.create": _req_admin_write(RBACPermission.CREDENTIAL_CREATE),
|
|
"models.credential.set": _req_admin_write(RBACPermission.CREDENTIAL_MANAGE),
|
|
"app_dsl.import": _REQ_DSL_WORKSPACE,
|
|
"app_dsl.check": _REQ_DSL_WORKSPACE,
|
|
"app_dsl.import_confirm": _REQ_DSL_WORKSPACE,
|
|
"app_dsl.export": _REQ_DSL_APP,
|
|
"app_dsl.check_dependencies": _REQ_DSL_APP,
|
|
"app_run.workflow": _RESOURCE_RUN,
|
|
"app_run.chat": _RESOURCE_RUN,
|
|
"app_run.advanced_chat": _RESOURCE_RUN,
|
|
"app_run.completion": _RESOURCE_RUN,
|
|
"app_run.draft.workflow": _draft_req(_auth_matrix_AppMode.WORKFLOW),
|
|
"app_run.draft.advanced_chat": _draft_req(_auth_matrix_AppMode.ADVANCED_CHAT),
|
|
"app_workflow.run.list": _REQ_RUN_HISTORY,
|
|
"app_workflow.run.describe": _REQ_RUN_HISTORY,
|
|
"app_workflow.run.nodes": _REQ_RUN_HISTORY,
|
|
"app_workflow.publish": _REQ_RELEASE,
|
|
"app_workflow.version.list": _REQ_VERSION_READ,
|
|
"app_workflow.version.restore": _REQ_RELEASE,
|
|
"app_workflow.release_check": _REQ_VERSION_READ,
|
|
"app_workflow.env.list": _REQ_VERSION_READ,
|
|
"app_workflow.env.set": _REQ_ENV_WRITE,
|
|
"app_workflow.env.delete": _REQ_ENV_WRITE,
|
|
"node_types.list": _REQ_NODE_TYPES,
|
|
"node_types.describe": _REQ_NODE_TYPES,
|
|
"app_create.workflow": _REQ_APP_CREATE,
|
|
"app_create.advanced_chat": _REQ_APP_CREATE,
|
|
"app_workflow.node_run.workflow": _draft_req(_auth_matrix_AppMode.WORKFLOW),
|
|
"app_workflow.node_run.advanced_chat": _draft_req(_auth_matrix_AppMode.ADVANCED_CHAT),
|
|
"app_info.describe.workflow": _settings_req(
|
|
(_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"app_info.set.workflow": _settings_req(
|
|
(_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP
|
|
),
|
|
"service_api.describe": _settings_req(
|
|
REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.APPS_READ, _EDITOR_UP
|
|
),
|
|
"service_api.set": _settings_req(
|
|
REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN
|
|
),
|
|
"app_info.describe.advanced_chat": _settings_req(
|
|
(_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"app_info.set.advanced_chat": _settings_req(
|
|
(_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP
|
|
),
|
|
"app_info.describe.chat": _settings_req(
|
|
(_auth_matrix_AppMode.CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"app_info.set.chat": _settings_req(
|
|
(_auth_matrix_AppMode.CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP
|
|
),
|
|
"app_info.describe.agent_chat": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"app_info.set.agent_chat": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP
|
|
),
|
|
"app_info.describe.completion": _settings_req(
|
|
(_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"app_info.set.completion": _settings_req(
|
|
(_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_EDIT, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP
|
|
),
|
|
"app_info.describe.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_PREVIEW, AgentBehindApp(), Scope.APPS_READ, None
|
|
),
|
|
"app_info.set.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_EDIT, AgentBehindApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP
|
|
),
|
|
"service_api.describe.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,),
|
|
RBACPermission.AGENT_ACCESS_POINT_VIEW,
|
|
AgentBehindApp(),
|
|
Scope.APPS_READ,
|
|
_EDITOR_UP,
|
|
),
|
|
"service_api.set.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,),
|
|
RBACPermission.AGENT_ACCESS_POINT_MANAGE,
|
|
AgentBehindApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_OWNER_ADMIN,
|
|
),
|
|
"webapp.describe.workflow": _settings_req(
|
|
(_auth_matrix_AppMode.WORKFLOW,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"webapp.set.workflow": _settings_req(
|
|
(_auth_matrix_AppMode.WORKFLOW,),
|
|
RBACPermission.APP_RELEASE_AND_VERSION,
|
|
PlainApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
"webapp.reset": _settings_req(
|
|
REGULAR_MODES, RBACPermission.APP_RELEASE_AND_VERSION, PlainApp(), Scope.WORKSPACE_WRITE, _OWNER_ADMIN
|
|
),
|
|
"webapp_access.describe": (
|
|
*_settings_req(REGULAR_MODES, RBACPermission.APP_ACCESS_CONFIG, PlainApp(), Scope.APPS_READ, _EDITOR_UP),
|
|
CheckWebAppAuthEnterprise(),
|
|
),
|
|
"webapp_access.set": (
|
|
*_settings_req(REGULAR_MODES, RBACPermission.APP_ACCESS_CONFIG, PlainApp(), Scope.WORKSPACE_WRITE, _EDITOR_UP),
|
|
CheckWebAppAuthEnterprise(),
|
|
),
|
|
"webapp.describe.advanced_chat": _settings_req(
|
|
(_auth_matrix_AppMode.ADVANCED_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"webapp.set.advanced_chat": _settings_req(
|
|
(_auth_matrix_AppMode.ADVANCED_CHAT,),
|
|
RBACPermission.APP_RELEASE_AND_VERSION,
|
|
PlainApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
"webapp.describe.chat": _settings_req(
|
|
(_auth_matrix_AppMode.CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"webapp.set.chat": _settings_req(
|
|
(_auth_matrix_AppMode.CHAT,),
|
|
RBACPermission.APP_RELEASE_AND_VERSION,
|
|
PlainApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
"webapp.describe.agent_chat": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT_CHAT,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"webapp.set.agent_chat": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT_CHAT,),
|
|
RBACPermission.APP_RELEASE_AND_VERSION,
|
|
PlainApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
"webapp.describe.completion": _settings_req(
|
|
(_auth_matrix_AppMode.COMPLETION,), RBACPermission.APP_VIEW_LAYOUT, PlainApp(), Scope.APPS_READ, None
|
|
),
|
|
"webapp.set.completion": _settings_req(
|
|
(_auth_matrix_AppMode.COMPLETION,),
|
|
RBACPermission.APP_RELEASE_AND_VERSION,
|
|
PlainApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
"webapp.describe.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,), RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp(), Scope.APPS_READ, None
|
|
),
|
|
"webapp.set.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,),
|
|
RBACPermission.AGENT_ACCESS_POINT_MANAGE,
|
|
AgentBehindApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
"webapp.reset.agent": _settings_req(
|
|
(_auth_matrix_AppMode.AGENT,),
|
|
RBACPermission.AGENT_ACCESS_POINT_MANAGE,
|
|
AgentBehindApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_OWNER_ADMIN,
|
|
),
|
|
"webapp_access.describe.agent": (
|
|
*_settings_req(
|
|
(_auth_matrix_AppMode.AGENT,),
|
|
RBACPermission.AGENT_ACCESS_CONFIG,
|
|
AgentBehindApp(),
|
|
Scope.APPS_READ,
|
|
_EDITOR_UP,
|
|
),
|
|
CheckWebAppAuthEnterprise(),
|
|
),
|
|
"webapp_access.set.agent": (
|
|
*_settings_req(
|
|
(_auth_matrix_AppMode.AGENT,),
|
|
RBACPermission.AGENT_ACCESS_CONFIG,
|
|
AgentBehindApp(),
|
|
Scope.WORKSPACE_WRITE,
|
|
_EDITOR_UP,
|
|
),
|
|
CheckWebAppAuthEnterprise(),
|
|
),
|
|
"app_run.stop": _RESOURCE_RUN,
|
|
"app_run.stop.task": _RESOURCE_RUN,
|
|
"files.upload": _REQ_FILES,
|
|
"human_input_form.get": _REQ_RUN_FORM,
|
|
"human_input_form.submit": _REQ_RUN_FORM,
|
|
"workflow_events.stream": _REQ_RUN,
|
|
"permitted_external.list": _REQ_EXTERNAL_LIST,
|
|
"permitted_external.describe": _REQ_EXTERNAL_DESCRIBE,
|
|
}
|
|
"""Every route's `@endpoint(requirements=...)`, in declared order. A route that
|
|
shares a tuple with a `MATRIX` representative is covered by that representative's
|
|
rows only while this table says it still declares the same tuple."""
|
|
|
|
|
|
ROUTES_BY_ID = {route.id: route for route in ROUTES}
|
|
|
|
|
|
def _applicable(route: Route, case: Case) -> bool:
|
|
return CASE_REQUIRES[case] <= route.traits
|
|
|
|
|
|
def _rows(route: Route) -> Iterator[tuple[Route, Case, Expect]]:
|
|
for case in Case:
|
|
if not _applicable(route, case):
|
|
continue
|
|
if case not in ROUTER_CASES:
|
|
yield route, case, MATRIX[route.id][case]
|
|
elif route.id == ROUTER_CASE_ROUTE:
|
|
yield route, case, ROUTER_CASES[case]
|
|
|
|
|
|
ROWS: tuple[tuple[Route, Case, Expect], ...] = tuple(
|
|
row for route in ROUTES if route.id in MATRIX for row in _rows(route)
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class World:
|
|
"""Persisted rows and the tokens that address them."""
|
|
|
|
workspace_id: str
|
|
other_workspace_id: str
|
|
app_id: str
|
|
disabled_app_id: str
|
|
member_account_id: str
|
|
low_role_account_id: str
|
|
outsider_account_id: str
|
|
tokens: dict[Bearer, str] = field(default_factory=dict)
|
|
session_ids: dict[Bearer, str] = field(default_factory=dict)
|
|
|
|
|
|
def _admit_on_mount(*_args: object, **_kwargs: object) -> None:
|
|
raise ImATeapot("admitted")
|
|
|
|
|
|
class _MemoryResolver:
|
|
"""Stands in for the DB-backed token resolver.
|
|
|
|
Token resolution is an input to the matrix, not part of it, and the shipped
|
|
resolver compares a timezone-aware expiry against a column SQLite hands back
|
|
naive.
|
|
"""
|
|
|
|
def __init__(self, rows: dict[str, ResolvedRow]) -> None:
|
|
self._rows = rows
|
|
|
|
def resolve(self, token_hash: str) -> ResolvedRow | None:
|
|
return self._rows.get(token_hash)
|
|
|
|
|
|
def _authenticator(rows: dict[str, ResolvedRow]) -> BearerAuthenticator:
|
|
resolver = _MemoryResolver(rows)
|
|
return BearerAuthenticator({TokenType.OAUTH_ACCOUNT: resolver, TokenType.OAUTH_EXTERNAL_SSO: resolver})
|
|
|
|
|
|
def _system_features(
|
|
*, edition: DeploymentEdition, webapp_auth: bool, license_status: LicenseStatus
|
|
) -> SystemFeatureModel:
|
|
features = SystemFeatureModel(agent_tracing_api_collection_enabled=False, deployment_edition=edition)
|
|
features.webapp_auth.enabled = webapp_auth
|
|
features.license.status = license_status
|
|
return features
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _WebAppSettings:
|
|
access_mode: str
|
|
|
|
|
|
def _access_mode_settings(access_mode: _auth_matrix_WebAppAccessMode | None) -> _WebAppSettings | None:
|
|
if access_mode is None:
|
|
return None
|
|
return _WebAppSettings(access_mode=access_mode.value)
|
|
|
|
|
|
def _webapp_account() -> Account:
|
|
"""The account an external-SSO subject's email resolves to for the private-app check."""
|
|
row = Account(name="external", email="external@example.com", avatar="", status=AccountStatus.ACTIVE)
|
|
row.id = str(uuid.uuid4())
|
|
return row
|
|
|
|
|
|
def _end_user(_type: EndUserType, tenant_id: str, app_id: str, user_id: str | None = None) -> EndUser:
|
|
row = EndUser(
|
|
tenant_id=tenant_id,
|
|
app_id=app_id,
|
|
type=EndUserType.OPENAPI,
|
|
_is_anonymous=False,
|
|
session_id=user_id or "",
|
|
)
|
|
row.external_user_id = user_id
|
|
return row
|
|
|
|
|
|
def _bearer_for(route: Route, scenario: Scenario) -> Bearer | None:
|
|
account_primary = Trait.ACCOUNT_PRIMARY in route.traits
|
|
match scenario.bearer:
|
|
case Bearer.NONE:
|
|
return None
|
|
case Bearer.PRIMARY:
|
|
return Bearer.ACCOUNT_MEMBER if account_primary else Bearer.EXTERNAL
|
|
case Bearer.OTHER_SUBJECT:
|
|
return Bearer.EXTERNAL if account_primary else Bearer.ACCOUNT_MEMBER
|
|
case _:
|
|
return scenario.bearer
|
|
|
|
|
|
def _url(route: Route, world: World, scenario: Scenario, bearer: Bearer | None) -> str:
|
|
if scenario.unknown_app:
|
|
app_id = str(uuid.uuid4())
|
|
elif not scenario.app_api_enabled:
|
|
app_id = world.disabled_app_id
|
|
else:
|
|
app_id = world.app_id
|
|
ids = {
|
|
"app_id": app_id,
|
|
"workspace_id": world.workspace_id,
|
|
# The caller's own session: the access service 404s any other id.
|
|
"session_id": world.session_ids.get(bearer, str(uuid.uuid4())) if bearer else str(uuid.uuid4()),
|
|
"member_id": str(uuid.uuid4()),
|
|
"import_id": str(uuid.uuid4()),
|
|
"task_id": str(uuid.uuid4()),
|
|
"form_token": uuid.uuid4().hex,
|
|
"run_id": str(uuid.uuid4()),
|
|
"version_id": str(uuid.uuid4()),
|
|
"skill_id": str(uuid.uuid4()),
|
|
"env_id": str(uuid.uuid4()),
|
|
"node_type": "llm",
|
|
"node_id": "node-1",
|
|
"plugin_id": "langgenius/openai",
|
|
"provider": "langgenius/openai/openai",
|
|
"credential_id": str(uuid.uuid4()),
|
|
"model_type": "llm",
|
|
}
|
|
query = route.query.format(**ids)
|
|
if scenario.foreign_workspace_query:
|
|
extra = f"workspace_id={world.other_workspace_id}"
|
|
query = f"{query}&{extra}" if query else extra
|
|
path = route.path.format(**ids)
|
|
return f"/openapi/v1{path}?{query}" if query else f"/openapi/v1{path}"
|
|
|
|
|
|
def _run_case(
|
|
*,
|
|
route: Route,
|
|
scenario: Scenario,
|
|
app: Flask,
|
|
world: World,
|
|
token_rows: dict[str, ResolvedRow],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> TestResponse:
|
|
edition = scenario.edition or (
|
|
DeploymentEdition.ENTERPRISE if Trait.ENTERPRISE_ONLY in route.traits else DeploymentEdition.COMMUNITY
|
|
)
|
|
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=edition, RBAC_ENABLED=scenario.rbac_enabled)
|
|
monkeypatch.setattr(oauth_bearer_module, "_authenticator", _authenticator(token_rows))
|
|
if scenario.narrow_scopes:
|
|
# No shipped token can fail `CheckScope`: `dfoa_` carries `Scope.FULL` and
|
|
# `dfoe_` exactly the scopes its routes ask for. Scopes are a constant of
|
|
# the subject, so the only way to reach the refusal is to blank them here.
|
|
# Keep these rows: they are what catches a third token kind routed
|
|
# somewhere it was not scoped for.
|
|
monkeypatch.setattr(oauth_bearer_module.AuthContext, "scopes", property(lambda _self: frozenset()))
|
|
|
|
features = _system_features(
|
|
edition=edition, webapp_auth=scenario.webapp_auth, license_status=scenario.license_status
|
|
)
|
|
settings = _access_mode_settings(scenario.access_mode)
|
|
|
|
headers: dict[str, str] = {CATALOG_HEADER: catalog_for(app)[1]}
|
|
bearer = _bearer_for(route, scenario)
|
|
if bearer is not None:
|
|
headers["Authorization"] = f"Bearer {world.tokens[bearer]}"
|
|
|
|
with ExitStack() as stack:
|
|
stack.enter_context(patch.object(SystemFeatureService, "get_public_system_features", return_value=features))
|
|
stack.enter_context(
|
|
patch.object(EnterpriseService.WebAppAuth, "get_app_access_mode_by_id", return_value=settings)
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
EnterpriseService.WebAppAuth,
|
|
"is_user_allowed_to_access_webapp",
|
|
return_value=scenario.private_app_permitted,
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
EnterpriseService.WebAppAuth,
|
|
"list_externally_accessible_apps",
|
|
return_value={"data": [], "total": 0, "hasMore": False},
|
|
)
|
|
)
|
|
services = subjects.application_services()
|
|
stack.enter_context(
|
|
patch.object(
|
|
services.app_scoped_end_users.commands,
|
|
"get_or_create_end_user_by_type",
|
|
side_effect=_end_user,
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"services.enterprise.rbac_service.RBACService.CheckAccess.check",
|
|
return_value=scenario.rbac_allows,
|
|
)
|
|
)
|
|
stack.enter_context(
|
|
patch.object(
|
|
services.accounts.identity,
|
|
"get_account_by_email",
|
|
return_value=_webapp_account(),
|
|
)
|
|
)
|
|
client = app.test_client()
|
|
return contextvars.copy_context().run(
|
|
lambda: client.open(_url(route, world, scenario, bearer), method=route.method, headers=headers)
|
|
)
|
|
|
|
|
|
_PATH_PARAMS: Final = frozenset({"plugin_id", "provider"})
|
|
|
|
|
|
def _rule_path(route: Route) -> str:
|
|
path = route.path
|
|
for name in _PATH_PARAMS:
|
|
path = path.replace(f"{{{name}}}", f"<path:{name}>")
|
|
return "/openapi/v1" + path.replace("{", "<string:").replace("}", ">")
|
|
|
|
|
|
def _endpoint_spec(app: Flask, rule: Rule, method: str) -> EndpointSpec | None:
|
|
"""The `EndpointSpec` `@endpoint` attached to this rule's handler, or None.
|
|
|
|
Carrying a spec *is* being guarded: `@endpoint` is the only thing that sets
|
|
it, and it sets it on the same object it wraps in `subject_router.guard`.
|
|
"""
|
|
view = app.view_functions.get(rule.endpoint)
|
|
resource = view.view_class if hasattr(view, "view_class") else None
|
|
if resource is None:
|
|
return None
|
|
handler = getattr(resource, method.lower(), None) # guard-ignore: no-new-getattr -- HTTP verb selects the handler
|
|
return handler.__spec__ if hasattr(handler, "__spec__") else None
|
|
|
|
|
|
@singledispatch
|
|
def _config(_requirement: Requirement) -> object:
|
|
return None
|
|
|
|
|
|
@_config.register
|
|
def _(requirement: CheckSubject) -> object:
|
|
return requirement.allowed
|
|
|
|
|
|
@_config.register
|
|
def _(requirement: CheckAppMode) -> object:
|
|
return requirement.modes
|
|
|
|
|
|
@_config.register
|
|
def _(requirement: CheckScope) -> object:
|
|
return requirement.scope
|
|
|
|
|
|
@_config.register
|
|
def _(requirement: CheckRBACPermission) -> object:
|
|
return tuple((check.scene, repr(check.locator)) for check in requirement.checks)
|
|
|
|
|
|
@_config.register
|
|
def _(requirement: CheckWorkspaceRole) -> object:
|
|
return requirement.allowed_roles
|
|
|
|
|
|
def _canonical(requirement: Requirement) -> tuple[type[Requirement], object]:
|
|
"""`Requirement` has no `__eq__`, so compare by type plus the config that sets it apart."""
|
|
return type(requirement), _config(requirement)
|
|
|
|
|
|
def _spec_for(app: Flask, route: Route) -> EndpointSpec | None:
|
|
for rule in app.url_map.iter_rules():
|
|
if str(rule) == _rule_path(route) and route.method in (rule.methods or set()):
|
|
return _endpoint_spec(app, rule, route.method)
|
|
return None
|
|
|
|
|
|
ERROR_DEFAULT_RESPONSE: dict[str, object] = {
|
|
"description": "Error",
|
|
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorBody"}}},
|
|
}
|
|
"""Exactly what `@returns` registers as `("default", "Error", ErrorBody)`, and what
|
|
`ExternalApi` adds to every other operation."""
|
|
|
|
EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = {
|
|
("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:ls"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:tree"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:grep"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:find"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("post", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:diff"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:cat"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/knowledge-fs/knowledge-spaces/{knowledge_space_id}/fs:stat"): frozenset(
|
|
{"200", "400", "401", "403", "404", "409", "413", "422", "503", "default"}
|
|
),
|
|
("get", "/_catalog"): frozenset({"200", "default"}),
|
|
("get", "/workspaces/{workspace_id}/marketplace/plugins"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/plugins"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/plugins:install"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/plugin-tasks/{task_id}"): frozenset({"200", "default"}),
|
|
("delete", "/workspaces/{workspace_id}/plugins/{plugin_id}"): frozenset({"200", "default"}),
|
|
("get", "/workspaces/{workspace_id}/tools"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/tool-providers/{provider}"): frozenset({"200", "default"}),
|
|
("post", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials"): frozenset({"201", "422", "default"}),
|
|
("patch", "/workspaces/{workspace_id}/tool-providers/{provider}/credentials/{credential_id}"): frozenset(
|
|
{"200", "422", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/model-providers/{provider}"): frozenset({"200", "default"}),
|
|
("post", "/workspaces/{workspace_id}/model-providers/{provider}/credentials"): frozenset({"201", "422", "default"}),
|
|
("patch", "/workspaces/{workspace_id}/model-providers/{provider}/credentials/{credential_id}"): frozenset(
|
|
{"200", "422", "default"}
|
|
),
|
|
("get", "/workspaces/{workspace_id}/knowledge-bases"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/skills"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/skills/{skill_id}"): frozenset({"200", "default"}),
|
|
("get", "/workspaces/{workspace_id}/skills/{skill_id}/versions"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/skills/{skill_id}/archive"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/skills/{skill_id}:publish"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/skills/imports"): frozenset({"200", "201", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}/models"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials"): frozenset(
|
|
{"201", "422", "default"}
|
|
),
|
|
("patch", "/workspaces/{workspace_id}/model-providers/{provider}/models/credentials/{credential_id}"): frozenset(
|
|
{"200", "422", "default"}
|
|
),
|
|
("get", "/_health"): frozenset({"200", "default"}),
|
|
("get", "/_version"): frozenset({"200", "default"}),
|
|
("get", "/account"): frozenset({"200", "default"}),
|
|
("get", "/account/sessions"): frozenset({"200", "422", "default"}),
|
|
("delete", "/account/sessions/self"): frozenset({"200", "default"}),
|
|
("delete", "/account/sessions/{session_id}"): frozenset({"200", "default"}),
|
|
("get", "/apps"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/app-info/workflow"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/app-info/workflow"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/service-api"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/service-api"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/app-info/advanced-chat"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/app-info/advanced-chat"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/app-info/chat"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/app-info/chat"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/app-info/agent-chat"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/app-info/agent-chat"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/app-info/completion"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/app-info/completion"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/app-info/agent"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/app-info/agent"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/service-api/agent"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/service-api/agent"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/dependencies:check"): frozenset({"200", "default"}),
|
|
("get", "/apps/{app_id}/dsl"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/files"): frozenset({"201", "400", "401", "413", "415", "422", "default"}),
|
|
("get", "/apps/{app_id}/human-input-forms/{form_token}"): frozenset({"200", "default"}),
|
|
("post", "/apps/{app_id}/human-input-forms/{form_token}:submit"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/runs/{run_id}/events"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/runs/{run_id}:stop"): frozenset({"200", "default"}),
|
|
("post", "/apps/{app_id}/tasks/{task_id}:stop"): frozenset({"200", "default"}),
|
|
("post", "/apps/{app_id}/workflow:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/chat:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/advanced-chat:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/completion:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/draft/workflow:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/draft/advanced-chat:run"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/runs"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/runs/{run_id}"): frozenset({"200", "default"}),
|
|
("get", "/apps/{app_id}/runs/{run_id}/nodes"): frozenset({"200", "default"}),
|
|
("post", "/apps/{app_id}:publish"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/versions"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/versions/{version_id}:restore"): frozenset({"200", "default"}),
|
|
("get", "/apps/{app_id}/release:check"): frozenset({"200", "default"}),
|
|
("get", "/apps/{app_id}/env"): frozenset({"200", "default"}),
|
|
("put", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "422", "default"}),
|
|
("delete", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "default"}),
|
|
# The catalog and the five device-flow rows document their 200 with a raw
|
|
# `openapi_ns.response` rather than `@returns`; `ExternalApi` still gives them the
|
|
# `default` error every operation documents, with the same `ErrorBody` schema.
|
|
# Approve and deny run Console account admission; its setup and account-initialization
|
|
# errors are generic on that surface (handled centrally by the client), so the guards
|
|
# document nothing and the open `default` error covers them. Code, token, approve and
|
|
# deny declare the RFC 8628-style `DeviceFlowErrorResponse` for the statuses their flow
|
|
# errors use.
|
|
("post", "/oauth/device/approve"): frozenset({"200", "404", "409", "503", "default"}),
|
|
("post", "/oauth/device/code"): frozenset({"200", "400", "default"}),
|
|
("post", "/oauth/device/deny"): frozenset({"200", "404", "409", "503", "default"}),
|
|
("get", "/oauth/device/lookup"): frozenset({"200", "default"}),
|
|
("post", "/oauth/device/token"): frozenset({"200", "400", "default"}),
|
|
("get", "/node-types"): frozenset({"200", "default"}),
|
|
("get", "/node-types/{node_type}"): frozenset({"200", "default"}),
|
|
("post", "/apps/{app_id}/draft/workflow/nodes/{node_id}:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/draft/advanced-chat/nodes/{node_id}:run"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/apps/workflow"): frozenset({"201", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/apps/advanced-chat"): frozenset({"201", "422", "default"}),
|
|
("get", "/permitted-external-apps"): frozenset({"200", "422", "default"}),
|
|
("get", "/permitted-external-apps/{app_id}"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces"): frozenset({"200", "422", "default"}),
|
|
("get", "/workspaces/{workspace_id}"): frozenset({"200", "default"}),
|
|
("post", "/workspaces/{workspace_id}/apps/imports"): frozenset({"200", "202", "400", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/apps/imports:check"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/apps/imports/{import_id}:confirm"): frozenset({"200", "400", "default"}),
|
|
("get", "/workspaces/{workspace_id}/members"): frozenset({"200", "422", "default"}),
|
|
("post", "/workspaces/{workspace_id}/members"): frozenset({"201", "422", "default"}),
|
|
("delete", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "default"}),
|
|
("patch", "/workspaces/{workspace_id}/members/{member_id}"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/webapp/workflow"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/webapp:reset"): frozenset({"200", "default"}),
|
|
("get", "/apps/{app_id}/webapp-access"): frozenset({"200", "default"}),
|
|
("put", "/apps/{app_id}/webapp-access"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/webapp/advanced-chat"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/webapp/advanced-chat"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/webapp/chat"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/webapp/chat"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/webapp/agent-chat"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/webapp/agent-chat"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/webapp/completion"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/webapp/completion"): frozenset({"200", "422", "default"}),
|
|
("get", "/apps/{app_id}/webapp/agent"): frozenset({"200", "default"}),
|
|
("patch", "/apps/{app_id}/webapp/agent"): frozenset({"200", "422", "default"}),
|
|
("post", "/apps/{app_id}/webapp/agent:reset"): frozenset({"200", "default"}),
|
|
("get", "/apps/{app_id}/webapp-access/agent"): frozenset({"200", "default"}),
|
|
("put", "/apps/{app_id}/webapp-access/agent"): frozenset({"200", "422", "default"}),
|
|
}
|
|
|
|
_HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"})
|
|
|
|
|
|
def _operations(document: dict[str, object]) -> Iterator[tuple[tuple[str, str], dict[str, object]]]:
|
|
paths = document["paths"]
|
|
assert isinstance(paths, dict)
|
|
for path, item in paths.items():
|
|
assert isinstance(item, dict)
|
|
for method, operation in item.items():
|
|
if method in _HTTP_METHODS:
|
|
assert isinstance(operation, dict)
|
|
yield (method, path), operation
|
|
|
|
|
|
class TestAuthMatrixCases:
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def token_rows() -> dict[str, ResolvedRow]:
|
|
return {}
|
|
|
|
@staticmethod
|
|
@pytest.fixture(scope="module")
|
|
def _matrix_app() -> Flask:
|
|
"""Register the matrix's unchanging routes once; each case gets a fresh client."""
|
|
app = create_flask_app_with_configs()
|
|
app.config["TESTING"] = True
|
|
app.secret_key = "openapi-auth-matrix"
|
|
LoginManager(app)
|
|
app.register_blueprint(openapi_bp)
|
|
return app
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def matrix_app(
|
|
_matrix_app: Flask, monkeypatch: pytest.MonkeyPatch, account_application_services: ApplicationServices
|
|
) -> Iterator[Flask]:
|
|
"""Keep rate-limit overrides and the admission probe scoped to each case."""
|
|
|
|
monkeypatch.setitem(_matrix_app.extensions, "application_services", account_application_services)
|
|
monkeypatch.setattr(
|
|
rate_limit_module,
|
|
"LIMIT_BEARER_PER_TOKEN",
|
|
rate_limit_module.RateLimit(
|
|
0,
|
|
rate_limit_module.LIMIT_BEARER_PER_TOKEN.window,
|
|
rate_limit_module.LIMIT_BEARER_PER_TOKEN.scopes,
|
|
),
|
|
)
|
|
|
|
user_logged_in.connect(_admit_on_mount)
|
|
try:
|
|
yield _matrix_app
|
|
finally:
|
|
user_logged_in.disconnect(_admit_on_mount)
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def world(sqlite_session_factory: sessionmaker[Session], token_rows: dict[str, ResolvedRow]) -> World:
|
|
"""One workspace with an owner, a normal member and an outsider; two apps."""
|
|
built = World(
|
|
workspace_id=str(uuid.uuid4()),
|
|
other_workspace_id=str(uuid.uuid4()),
|
|
app_id=str(uuid.uuid4()),
|
|
disabled_app_id=str(uuid.uuid4()),
|
|
member_account_id=str(uuid.uuid4()),
|
|
low_role_account_id=str(uuid.uuid4()),
|
|
outsider_account_id=str(uuid.uuid4()),
|
|
)
|
|
|
|
def account(account_id: str, email: str) -> Account:
|
|
row = Account(name=email, email=email, avatar="", status=AccountStatus.ACTIVE)
|
|
row.id = account_id
|
|
return row
|
|
|
|
def application(app_id: str, *, enable_api: bool) -> App:
|
|
return App(
|
|
id=app_id,
|
|
tenant_id=built.workspace_id,
|
|
name="matrix app",
|
|
description="",
|
|
mode=_auth_matrix_AppMode.WORKFLOW,
|
|
enable_site=False,
|
|
enable_api=enable_api,
|
|
)
|
|
|
|
workspace = Tenant(name="matrix workspace")
|
|
workspace.id = built.workspace_id
|
|
other_workspace = Tenant(name="other workspace")
|
|
other_workspace.id = built.other_workspace_id
|
|
|
|
rows: list[object] = [
|
|
workspace,
|
|
other_workspace,
|
|
account(built.member_account_id, "owner@example.com"),
|
|
account(built.low_role_account_id, "normal@example.com"),
|
|
account(built.outsider_account_id, "outsider@example.com"),
|
|
application(built.app_id, enable_api=True),
|
|
application(built.disabled_app_id, enable_api=False),
|
|
TenantAccountJoin(
|
|
tenant_id=built.workspace_id,
|
|
account_id=built.member_account_id,
|
|
role=TenantAccountRole.OWNER,
|
|
),
|
|
TenantAccountJoin(
|
|
tenant_id=built.workspace_id,
|
|
account_id=built.low_role_account_id,
|
|
role=TenantAccountRole.NORMAL,
|
|
),
|
|
]
|
|
|
|
def mint(bearer: Bearer, prefix: str, *, account_id: str | None, email: str) -> None:
|
|
"""Register a bearer with the fake resolver *and* persist the session row it
|
|
names, so the access service's ownership check has something real to match against.
|
|
"""
|
|
raw = prefix + uuid.uuid4().hex
|
|
token_id = uuid.uuid4()
|
|
issuer = "dify:account" if account_id else "https://idp.example"
|
|
token_rows[sha256_hex(raw)] = ResolvedRow(
|
|
subject_email=email,
|
|
subject_issuer=issuer,
|
|
account_id=uuid.UUID(account_id) if account_id else None,
|
|
client_id="difyctl",
|
|
token_id=token_id,
|
|
expires_at=None,
|
|
)
|
|
row = OAuthAccessToken(
|
|
token_hash=sha256_hex(raw),
|
|
prefix=prefix,
|
|
account_id=account_id,
|
|
subject_email=email,
|
|
subject_issuer=issuer,
|
|
client_id="difyctl",
|
|
device_label="matrix",
|
|
expires_at=datetime.now(UTC) + timedelta(days=365),
|
|
)
|
|
row.id = str(token_id)
|
|
rows.append(row)
|
|
built.tokens[bearer] = raw
|
|
built.session_ids[bearer] = str(token_id)
|
|
|
|
mint(
|
|
Bearer.ACCOUNT_MEMBER,
|
|
TokenType.OAUTH_ACCOUNT.prefix,
|
|
account_id=built.member_account_id,
|
|
email="owner@example.com",
|
|
)
|
|
mint(
|
|
Bearer.ACCOUNT_LOW_ROLE,
|
|
TokenType.OAUTH_ACCOUNT.prefix,
|
|
account_id=built.low_role_account_id,
|
|
email="normal@example.com",
|
|
)
|
|
mint(
|
|
Bearer.ACCOUNT_OUTSIDER,
|
|
TokenType.OAUTH_ACCOUNT.prefix,
|
|
account_id=built.outsider_account_id,
|
|
email="outsider@example.com",
|
|
)
|
|
mint(Bearer.EXTERNAL, TokenType.OAUTH_EXTERNAL_SSO.prefix, account_id=None, email="external@example.com")
|
|
with sqlite_session_factory.begin() as session:
|
|
session.add_all(rows)
|
|
return built
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize(
|
|
("route", "case", "expected"),
|
|
ROWS,
|
|
ids=[f"{route.id}-{case.value}" for route, case, _ in ROWS],
|
|
)
|
|
def test_allow_deny_matrix(
|
|
route: Route,
|
|
case: Case,
|
|
expected: Expect,
|
|
matrix_app: Flask,
|
|
world: World,
|
|
token_rows: dict[str, ResolvedRow],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
response = _run_case(
|
|
route=route,
|
|
scenario=SCENARIOS[case],
|
|
app=matrix_app,
|
|
world=world,
|
|
token_rows=token_rows,
|
|
monkeypatch=monkeypatch,
|
|
)
|
|
|
|
body = response.get_json(silent=True) or {}
|
|
assert response.status_code == expected.status, f"{route.id}/{case.value}: body={body}"
|
|
if expected.message is not None:
|
|
assert body.get("message") == expected.message
|
|
|
|
@staticmethod
|
|
def test_registered_openapi_routes_match_the_matrix(matrix_app: Flask) -> None:
|
|
"""Every guarded /openapi/v1 route is in the table, and nothing else is.
|
|
|
|
Guarded-ness is derived from `view.__spec__`, not from a hand-kept list of
|
|
exemptions — so a route that gains a guard without gaining a row fails here,
|
|
and so does a guarded route that quietly stops carrying a spec. Neither can
|
|
be silenced by editing this file.
|
|
"""
|
|
guarded: set[tuple[str, str]] = set()
|
|
unguarded: set[tuple[str, str]] = set()
|
|
for rule in matrix_app.url_map.iter_rules():
|
|
if not str(rule).startswith("/openapi/v1") or str(rule) == "/openapi/v1/":
|
|
continue
|
|
for method in rule.methods or set():
|
|
if method in {"HEAD", "OPTIONS"}:
|
|
continue
|
|
entry = (method, str(rule))
|
|
target = guarded if _endpoint_spec(matrix_app, rule, method) is not None else unguarded
|
|
target.add(entry)
|
|
|
|
expected = {(route.method, _rule_path(route)) for route in ROUTES}
|
|
assert guarded == expected
|
|
# The remainder is the device-flow, documentation and catalog surface. Its size is
|
|
# pinned rather than enumerated: a list of exemptions rots silently — the one this
|
|
# replaced still named `swagger.json`, a route that is not registered at all.
|
|
assert len(unguarded) == 14
|
|
|
|
@staticmethod
|
|
@pytest.mark.parametrize("route", ROUTES, ids=[route.id for route in ROUTES])
|
|
def test_route_declares_expected_requirements(route: Route, matrix_app: Flask) -> None:
|
|
"""The guard that lets `MATRIX` run one representative per tuple: a route
|
|
that drifts from the tuple its representative was chosen for fails here.
|
|
"""
|
|
spec = _spec_for(matrix_app, route)
|
|
assert spec is not None, route.id
|
|
assert [*map(_canonical, spec.requirements)] == [*map(_canonical, DECLARED[route.id])]
|
|
|
|
@staticmethod
|
|
@pytest.fixture
|
|
def openapi_document(config_overrides: Callable[..., None]) -> dict[str, object]:
|
|
config_overrides(SWAGGER_UI_ENABLED=True)
|
|
app = Flask(__name__)
|
|
app.config["TESTING"] = True
|
|
app.register_blueprint(openapi_bp)
|
|
response = app.test_client().get("/openapi/v1/openapi.json")
|
|
assert response.status_code == 200
|
|
return response.get_json()
|
|
|
|
@staticmethod
|
|
def test_file_bearing_bodies_are_documented_as_multipart(openapi_document: dict[str, object]) -> None:
|
|
"""The exported contract names the wire form `_multipart.py` accepts: a body of only
|
|
files is multipart alone, a run body is JSON or multipart with its JSON-text parts marked."""
|
|
paths = openapi_document["paths"]
|
|
assert isinstance(paths, dict)
|
|
upload = paths["/apps/{app_id}/files"]["post"]["requestBody"]["content"]
|
|
assert set(upload) == {"multipart/form-data"}
|
|
assert "encoding" not in upload["multipart/form-data"]
|
|
run = paths["/apps/{app_id}/chat:run"]["post"]["requestBody"]["content"]
|
|
assert set(run) == {"application/json", "multipart/form-data"}
|
|
assert run["multipart/form-data"]["schema"] == run["application/json"]["schema"]
|
|
encoding = run["multipart/form-data"]["encoding"]
|
|
assert {"inputs", "query"} <= set(encoding)
|
|
assert {"files", "attachments"}.isdisjoint(encoding)
|
|
assert encoding["inputs"] == {"contentType": "application/json"}
|
|
|
|
@staticmethod
|
|
def test_openapi_document_operations_and_response_codes(openapi_document: dict[str, object]) -> None:
|
|
"""Every operation's response codes are pinned exactly.
|
|
|
|
`@endpoint` routes through `@returns`, which registers `default` -> `ErrorBody`,
|
|
so a route that stops emitting the error schema (or one that starts) moves a row
|
|
here and has to be re-pinned deliberately.
|
|
"""
|
|
seen = dict(_operations(openapi_document))
|
|
assert set(seen) == set(EXPECTED_RESPONSE_CODES)
|
|
for key, operation in seen.items():
|
|
responses = operation.get("responses", {})
|
|
assert isinstance(responses, dict)
|
|
assert frozenset(responses) == EXPECTED_RESPONSE_CODES[key], (
|
|
f"{key}: {sorted(responses)} != {sorted(EXPECTED_RESPONSE_CODES[key])}"
|
|
)
|
|
|
|
@staticmethod
|
|
def test_every_default_response_is_the_one_returns_registers(openapi_document: dict[str, object]) -> None:
|
|
"""A hand-written `default` pointing at some other schema would still satisfy
|
|
the code sets above, which compare codes and not bodies.
|
|
"""
|
|
for key, operation in _operations(openapi_document):
|
|
responses = operation["responses"]
|
|
assert isinstance(responses, dict)
|
|
if "default" in responses:
|
|
assert responses["default"] == ERROR_DEFAULT_RESPONSE, key
|