mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 18:25:48 +00:00
* feat(authz): add built-in RBAC provider and provider factory (Phase 1A-2, #4063) Phase 1A-2: RBAC provider + provider factory. No runtime behavior change. New authz/rbac.py — RbacAuthorizationProvider: - allow: '*' / True / list / [] / missing → deny-wins semantics - deny always overrides all allow forms - resource name explicit mapping (tool→tools, model→models, etc.) - unknown/missing role raises ValueError (never silent allow) - config compiled to immutable frozensets at construction - filter_resources preserves order, no mutation, consistent with authorize - sync == async decisions New authz/runtime.py — resolve_authorization_provider: - disabled → None (no import attempted) - enabled + no provider → ValueError - invalid class path / construction failure → ValueError with path - isinstance Protocol check post-construction - no caching, no fail_closed/default_role injection 48 tests (37 RBAC + 11 factory). No config schema change, no config_version bump. Per RFC #4063 Phase 1A-2. Layer 1/Layer 2 wiring deferred to Phase 1B. * fix(authz): reject unknown RBAC provider config Fail fast on misspelled top-level RBAC settings, cover factory error propagation, and record Phase 1B policy and audit caveats. * fix(authz): reject unreachable resource aliases Fail fast when RBAC config uses reserved request-side aliases, preserve same-name and custom resources, and add regression coverage for every mapped alias. * fix(authz): validate RBAC request identifiers
212 lines
8.1 KiB
Python
212 lines
8.1 KiB
Python
"""Tests for resolve_authorization_provider — the provider factory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from deerflow.authz.provider import AuthorizationProvider
|
|
from deerflow.authz.runtime import resolve_authorization_provider
|
|
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
|
|
|
|
|
class TestDisabled:
|
|
"""enabled=False must return None without importing anything."""
|
|
|
|
def test_disabled_returns_none(self):
|
|
config = AuthorizationConfig(enabled=False)
|
|
assert resolve_authorization_provider(config) is None
|
|
|
|
def test_disabled_with_provider_config_returns_none(self):
|
|
"""Even with a provider configured, disabled means None."""
|
|
config = AuthorizationConfig(
|
|
enabled=False,
|
|
provider=AuthorizationProviderConfig(use="nonexistent.module:FakeProvider"),
|
|
)
|
|
assert resolve_authorization_provider(config) is None
|
|
|
|
|
|
class TestMissingProvider:
|
|
"""enabled=True but no provider configured must fail clearly."""
|
|
|
|
def test_enabled_without_provider_raises(self):
|
|
config = AuthorizationConfig(enabled=True)
|
|
with pytest.raises(ValueError, match="no provider is configured"):
|
|
resolve_authorization_provider(config)
|
|
|
|
|
|
class TestValidProvider:
|
|
"""Built-in and custom providers resolve through the same path."""
|
|
|
|
def test_builtin_rbac_resolves(self):
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"admin": {"tools": {"allow": "*"}}}},
|
|
),
|
|
)
|
|
provider = resolve_authorization_provider(config)
|
|
assert provider is not None
|
|
assert isinstance(provider, AuthorizationProvider)
|
|
assert provider.name == "rbac"
|
|
|
|
def test_custom_provider_resolves(self):
|
|
"""A provider defined in an importable module resolves through the same path."""
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"user": {"tools": {"allow": ["web_search"]}}}},
|
|
),
|
|
)
|
|
provider = resolve_authorization_provider(config)
|
|
assert provider is not None
|
|
|
|
|
|
class TestInvalidClassPath:
|
|
"""Invalid class paths must raise with the path in the error."""
|
|
|
|
def test_nonexistent_module_raises_with_path(self):
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(use="nonexistent.module:FakeProvider"),
|
|
)
|
|
with pytest.raises(ValueError, match="nonexistent.module"):
|
|
resolve_authorization_provider(config)
|
|
|
|
def test_nonexistent_attribute_raises_with_path(self):
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(use="deerflow.authz.rbac:NonexistentProvider"),
|
|
)
|
|
with pytest.raises(ValueError, match="NonexistentProvider"):
|
|
resolve_authorization_provider(config)
|
|
|
|
|
|
class TestProtocolConformance:
|
|
"""Instance not satisfying AuthorizationProvider must fail."""
|
|
|
|
def test_non_protocol_class_raises(self):
|
|
"""A class that constructs successfully but doesn't implement all
|
|
Protocol methods must fail the isinstance check."""
|
|
# `builtins:dict` constructs as empty dict(), which is not an
|
|
# AuthorizationProvider — it lacks name/authorize/aauthorize/filter_resources.
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="builtins:dict",
|
|
config={},
|
|
),
|
|
)
|
|
with pytest.raises(ValueError, match="AuthorizationProvider Protocol"):
|
|
resolve_authorization_provider(config)
|
|
|
|
def test_non_class_target_raises(self):
|
|
"""A class path pointing to a non-class (e.g. a function) must fail
|
|
because resolve_variable is called with expected_type=type."""
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="builtins:print",
|
|
config={},
|
|
),
|
|
)
|
|
with pytest.raises(ValueError, match="Failed to resolve"):
|
|
resolve_authorization_provider(config)
|
|
|
|
|
|
class TestRbacErrorPropagation:
|
|
"""Factory must surface RBAC construction errors with class path and __cause__."""
|
|
|
|
def test_unknown_provider_config_key_surfaces_through_factory(self):
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"user": {}}, "bogus": True},
|
|
),
|
|
)
|
|
with pytest.raises(ValueError, match="RbacAuthorizationProvider.*bogus") as exc_info:
|
|
resolve_authorization_provider(config)
|
|
assert isinstance(exc_info.value.__cause__, ValueError)
|
|
|
|
def test_invalid_rbac_config_surfaces_class_path(self):
|
|
"""RBAC construction failure (e.g. bad roles) must produce a ValueError
|
|
containing the class path."""
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": "not a dict"},
|
|
),
|
|
)
|
|
with pytest.raises(ValueError, match="RbacAuthorizationProvider"):
|
|
resolve_authorization_provider(config)
|
|
|
|
def test_invalid_rbac_config_preserves_cause(self):
|
|
"""The original construction error must be chained as __cause__."""
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"user": {"tools": {"allow": 42}}}},
|
|
),
|
|
)
|
|
try:
|
|
resolve_authorization_provider(config)
|
|
except ValueError as err:
|
|
assert err.__cause__ is not None
|
|
assert isinstance(err.__cause__, ValueError)
|
|
else:
|
|
pytest.fail("Expected ValueError for invalid RBAC config")
|
|
|
|
|
|
class TestNoFactoryInjection:
|
|
"""Factory must not inject fail_closed or default_role into provider kwargs."""
|
|
|
|
def test_factory_does_not_inject_framework_params(self):
|
|
"""The provider constructor should only receive config.config kwargs,
|
|
not fail_closed/default_role from AuthorizationConfig."""
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
fail_closed=False,
|
|
default_role="guest",
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"guest": {"tools": {"allow": "*"}}}},
|
|
),
|
|
)
|
|
# Should construct successfully without injecting fail_closed/default_role
|
|
provider = resolve_authorization_provider(config)
|
|
assert provider is not None
|
|
|
|
|
|
class TestNoCaching:
|
|
"""Factory must not cache instances."""
|
|
|
|
def test_each_call_returns_new_instance(self):
|
|
config = AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"admin": {"tools": {"allow": "*"}}}},
|
|
),
|
|
)
|
|
p1 = resolve_authorization_provider(config)
|
|
p2 = resolve_authorization_provider(config)
|
|
assert p1 is not p2
|
|
|
|
|
|
class TestDisabledNoImport:
|
|
"""disabled must not trigger any import of the provider module."""
|
|
|
|
def test_disabled_does_not_import_invalid_path(self):
|
|
"""Even with a garbage class path, disabled must return None
|
|
without raising ImportError."""
|
|
config = AuthorizationConfig(
|
|
enabled=False,
|
|
provider=AuthorizationProviderConfig(use="garbage.that.does.not.exist:Nope"),
|
|
)
|
|
# Must not raise
|
|
assert resolve_authorization_provider(config) is None
|