mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(authz): add plugin resource targets, provenance, action and management enforcement
PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.
- `authz/plugin_targets.py` is the single encoder/validator for composite
targets (`namespace/part`), with one validating composer behind three
kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
`plugin_management`, plus the batch filters the page slice will consume):
disabled is a no-op, an explicit deny raises, and provider errors follow
`fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
`arequire_plugin_management` with two `app.state` resolver keys; the
Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
display (assembly descriptor, Layer-2 context later); no decision reads a
label.
- Plugin providers use a `(config signature, loop key)` cache so a
loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
`config_version` 47 -> 48 across the three mirrors.
Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.
* fix(authz): decide plugin resources from the request's config snapshot
Review round 1 on PR #5842 (willem-bd).
P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.
P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.
Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.
* fix(authz): treat a request-time config absence as unavailable
Review round 2 on PR #5842 (willem-bd).
P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.
P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.
Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.
* fix(authz): apply the running policy when the plugin config is unreadable
Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).
The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.
The rule is now "has this process loaded a configuration?", not "was the last
read successful?":
- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
`deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
*disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
failure flag of the configuration this process runs on (authorization
disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
default), so an enabled Gateway answers 403/False in the window instead of
letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
closed when nothing is loaded (round 1's rule), otherwise the loaded policy
decides.
`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.
Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.
* fix(authz): keep a denial whose reasons raise while being read
* fix(authz): intersect plugin batch filters with the candidates
The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.
* docs(plugins): map a management denial to 403 in the route example
The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.
* docs(extensions): point the 0.2.4 contract row at the PR that ships it
Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
452 lines
20 KiB
Python
452 lines
20 KiB
Python
"""Tests for the built-in RbacAuthorizationProvider."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from deerflow.authz.provider import AuthzRequest, Principal
|
|
from deerflow.authz.rbac import RbacAuthorizationProvider
|
|
|
|
# --- Helpers ---
|
|
|
|
|
|
def _make_request(
|
|
*,
|
|
role: str = "user",
|
|
resource: str = "tool",
|
|
action: str = "call",
|
|
target: str = "bash",
|
|
) -> AuthzRequest:
|
|
return AuthzRequest(
|
|
principal=Principal(role=role),
|
|
resource=resource,
|
|
action=action,
|
|
target=target,
|
|
)
|
|
|
|
|
|
def _provider(roles: dict) -> RbacAuthorizationProvider:
|
|
return RbacAuthorizationProvider(roles=roles)
|
|
|
|
|
|
# --- Allow semantics ---
|
|
|
|
|
|
class TestAllowSemantics:
|
|
"""Verify all forms of `allow` configuration."""
|
|
|
|
def test_wildcard_allow(self):
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is True
|
|
assert p.authorize(_make_request(target="write_file")).allow is True
|
|
|
|
def test_boolean_true_allow(self):
|
|
p = _provider({"user": {"tools": {"allow": True}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is True
|
|
|
|
def test_boolean_false_deny_all(self):
|
|
p = _provider({"user": {"tools": {"allow": False}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
assert p.authorize(_make_request(target="web_search")).allow is False
|
|
|
|
def test_list_allow(self):
|
|
p = _provider({"user": {"tools": {"allow": ["web_search", "read_file"]}}})
|
|
assert p.authorize(_make_request(target="web_search")).allow is True
|
|
assert p.authorize(_make_request(target="read_file")).allow is True
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
|
|
def test_empty_list_deny_all(self):
|
|
p = _provider({"user": {"tools": {"allow": []}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
|
|
def test_allow_missing_defaults_to_allow_all(self):
|
|
"""Missing `allow` means unrestricted (deny still applies)."""
|
|
p = _provider({"user": {"tools": {"deny": ["bash"]}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
assert p.authorize(_make_request(target="web_search")).allow is True
|
|
|
|
|
|
# --- Deny semantics ---
|
|
|
|
|
|
class TestDenySemantics:
|
|
"""Deny always wins over allow."""
|
|
|
|
def test_deny_overrides_wildcard(self):
|
|
p = _provider({"user": {"tools": {"allow": "*", "deny": ["bash"]}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
assert p.authorize(_make_request(target="web_search")).allow is True
|
|
|
|
def test_deny_overrides_list_allow(self):
|
|
p = _provider({"user": {"tools": {"allow": ["bash", "web_search"], "deny": ["bash"]}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
assert p.authorize(_make_request(target="web_search")).allow is True
|
|
|
|
def test_deny_overrides_boolean_true(self):
|
|
p = _provider({"user": {"tools": {"allow": True, "deny": ["bash"]}}})
|
|
assert p.authorize(_make_request(target="bash")).allow is False
|
|
|
|
|
|
# --- Resource mapping ---
|
|
|
|
|
|
class TestResourceMapping:
|
|
"""tool → tools, model → models, etc."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("request_alias", "config_key"),
|
|
[
|
|
("tool", "tools"),
|
|
("model", "models"),
|
|
("skill", "skills"),
|
|
("mcp_server", "mcp_servers"),
|
|
("route", "routes"),
|
|
("plugin_action", "plugin_actions"),
|
|
],
|
|
)
|
|
def test_reserved_request_alias_is_rejected(self, request_alias, config_key):
|
|
with pytest.raises(ValueError, match=rf"resource key '{request_alias}'.*use '{config_key}'"):
|
|
_provider({"user": {request_alias: {"allow": []}}})
|
|
|
|
def test_alias_is_rejected_when_mapped_key_is_also_configured(self):
|
|
with pytest.raises(ValueError, match=r"resource key 'tool'.*use 'tools'"):
|
|
_provider(
|
|
{
|
|
"user": {
|
|
"tools": {"allow": "*"},
|
|
"tool": {"allow": []},
|
|
}
|
|
}
|
|
)
|
|
|
|
def test_same_name_mapping_is_valid(self):
|
|
p = _provider({"user": {"sandbox": {"allow": []}}})
|
|
assert p.authorize(_make_request(resource="sandbox", target="default")).allow is False
|
|
|
|
def test_tool_maps_to_tools(self):
|
|
p = _provider({"user": {"tools": {"allow": ["web_search"]}}})
|
|
assert p.authorize(_make_request(resource="tool", target="web_search")).allow is True
|
|
assert p.authorize(_make_request(resource="tool", target="bash")).allow is False
|
|
|
|
def test_model_maps_to_models(self):
|
|
p = _provider({"user": {"models": {"allow": ["gpt-4o"]}}})
|
|
assert p.authorize(_make_request(resource="model", target="gpt-4o")).allow is True
|
|
|
|
def test_unknown_resource_uses_original_name(self):
|
|
p = _provider({"user": {"custom_resource": {"allow": ["item1"]}}})
|
|
assert p.authorize(_make_request(resource="custom_resource", target="item1")).allow is True
|
|
|
|
def test_resource_config_missing_means_unrestricted(self):
|
|
"""If a role has no policy for a resource type, it's unrestricted."""
|
|
p = _provider({"user": {"tools": {"allow": ["web_search"]}}})
|
|
# No model policy configured → unrestricted
|
|
assert p.authorize(_make_request(resource="model", target="any")).allow is True
|
|
|
|
def test_plugin_action_maps_to_plugin_actions(self):
|
|
"""The request resource is singular; the config key is plural."""
|
|
p = _provider({"user": {"plugin_actions": {"allow": ["community.check/check"]}}})
|
|
request = _make_request(resource="plugin_action", action="invoke", target="community.check/check")
|
|
assert p.authorize(request).allow is True
|
|
assert p.authorize(_make_request(resource="plugin_action", action="invoke", target="community.check/other")).allow is False
|
|
assert p.filter_resources(Principal(role="user"), "plugin_action", ["community.check/check", "community.check/other"]) == ["community.check/check"]
|
|
|
|
def test_plugin_actions_alias_direction_denies_when_empty(self):
|
|
"""A reversed alias would silently allow; the mapping is request → config key."""
|
|
p = _provider({"user": {"plugin_actions": {"allow": []}}})
|
|
assert p.authorize(_make_request(resource="plugin_action", action="invoke", target="community.check/check")).allow is False
|
|
|
|
def test_plugin_management_is_self_mapped(self):
|
|
"""Key == resource is legal (same shape as ``sandbox``)."""
|
|
p = _provider({"user": {"plugin_management": {"allow": ["community.check/permissions.read"]}}})
|
|
allowed = p.authorize(_make_request(resource="plugin_management", action="read", target="community.check/permissions.read"))
|
|
denied = p.authorize(_make_request(resource="plugin_management", action="write", target="community.check/permissions.write"))
|
|
assert allowed.allow is True
|
|
assert denied.allow is False
|
|
|
|
def test_plugin_management_separates_read_from_write_by_target(self):
|
|
"""The built-in provider ignores ``action``, so authority must differ by target."""
|
|
from deerflow.authz.plugin_targets import MANAGEMENT_READ_PART, MANAGEMENT_WRITE_PART, plugin_management_target
|
|
|
|
read_target = plugin_management_target("community.check", MANAGEMENT_READ_PART)
|
|
write_target = plugin_management_target("community.check", MANAGEMENT_WRITE_PART)
|
|
p = _provider({"user": {"plugin_management": {"allow": [read_target]}}})
|
|
|
|
assert p.authorize(_make_request(resource="plugin_management", action="read", target=read_target)).allow is True
|
|
# The same call under the write action is still denied: the target carries the authority.
|
|
assert p.authorize(_make_request(resource="plugin_management", action="write", target=write_target)).allow is False
|
|
assert p.authorize(_make_request(resource="plugin_management", action="write", target=read_target)).allow is True
|
|
|
|
|
|
# --- Role resolution ---
|
|
|
|
|
|
class TestRoleResolution:
|
|
"""Unknown and missing roles must fail."""
|
|
|
|
def test_known_role_works(self):
|
|
p = _provider({"admin": {"tools": {"allow": "*"}}, "user": {"tools": {"allow": []}}})
|
|
assert p.authorize(_make_request(role="admin", target="bash")).allow is True
|
|
assert p.authorize(_make_request(role="user", target="bash")).allow is False
|
|
|
|
def test_unknown_role_raises(self):
|
|
"""Unknown role must raise ValueError, not return allow."""
|
|
p = _provider({"admin": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="Unknown role"):
|
|
p.authorize(_make_request(role="editor", target="bash"))
|
|
|
|
def test_missing_role_raises(self):
|
|
"""None role must raise ValueError."""
|
|
p = _provider({"admin": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="no role"):
|
|
p.authorize(_make_request(role=None, target="bash"))
|
|
|
|
def test_empty_string_role_raises(self):
|
|
p = _provider({"admin": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="no role"):
|
|
p.authorize(_make_request(role="", target="bash"))
|
|
|
|
|
|
# --- filter_resources ---
|
|
|
|
|
|
class TestFilterResources:
|
|
"""Batch visibility filter."""
|
|
|
|
def test_filter_preserves_order(self):
|
|
p = _provider({"user": {"tools": {"allow": ["web_search", "bash", "read_file"]}}})
|
|
result = p.filter_resources(Principal(role="user"), "tool", ["bash", "web_search", "write_file", "read_file"])
|
|
assert result == ["bash", "web_search", "read_file"]
|
|
|
|
def test_filter_no_duplicates_added(self):
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
result = p.filter_resources(Principal(role="user"), "tool", ["a", "b"])
|
|
assert result == ["a", "b"]
|
|
|
|
def test_filter_preserves_input_duplicates(self):
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
result = p.filter_resources(Principal(role="user"), "tool", ["a", "a", "b"])
|
|
assert result == ["a", "a", "b"]
|
|
|
|
def test_filter_does_not_modify_input(self):
|
|
p = _provider({"user": {"tools": {"allow": ["a"]}}})
|
|
candidates = ["a", "b", "c"]
|
|
p.filter_resources(Principal(role="user"), "tool", candidates)
|
|
assert candidates == ["a", "b", "c"]
|
|
|
|
def test_filter_unrestricted_when_no_policy(self):
|
|
p = _provider({"admin": {}})
|
|
result = p.filter_resources(Principal(role="admin"), "tool", ["a", "b"])
|
|
assert result == ["a", "b"]
|
|
|
|
def test_filter_consistent_with_authorize(self):
|
|
"""filter_resources result must match per-item authorize decisions."""
|
|
p = _provider({"user": {"tools": {"allow": "*", "deny": ["bash"]}}})
|
|
candidates = ["bash", "web_search", "read_file", "write_file"]
|
|
filtered = p.filter_resources(Principal(role="user"), "tool", candidates)
|
|
per_item = [c for c in candidates if p.authorize(_make_request(target=c)).allow]
|
|
assert filtered == per_item
|
|
|
|
@pytest.mark.parametrize("role", [None, ""])
|
|
def test_filter_missing_role_raises(self, role):
|
|
"""Visibility filtering propagates missing-role errors like authorize."""
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="no role"):
|
|
p.filter_resources(Principal(role=role), "tool", ["bash"])
|
|
|
|
def test_filter_unknown_role_raises(self):
|
|
"""Visibility filtering propagates unknown-role errors like authorize."""
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="Unknown role"):
|
|
p.filter_resources(Principal(role="editor"), "tool", ["bash"])
|
|
|
|
@pytest.mark.parametrize("resource_type", [None, ""])
|
|
def test_filter_invalid_resource_type_raises(self, resource_type):
|
|
p = _provider({"user": {}})
|
|
with pytest.raises(ValueError, match="resource_type must be a non-empty string"):
|
|
p.filter_resources(Principal(role="user"), resource_type, ["bash"])
|
|
|
|
@pytest.mark.parametrize(
|
|
"roles",
|
|
[
|
|
{"user": {}},
|
|
{"user": {"tools": {"allow": "*"}}},
|
|
{"user": {"tools": {"allow": ["bash"]}}},
|
|
],
|
|
ids=["unrestricted", "wildcard", "allow-list"],
|
|
)
|
|
@pytest.mark.parametrize("candidates", [["bash", None], ["bash", ""]], ids=["null", "empty"])
|
|
def test_filter_invalid_candidate_raises_for_every_policy_shape(self, roles, candidates):
|
|
p = _provider(roles)
|
|
with pytest.raises(ValueError, match=r"candidates\[1\] must be a non-empty string"):
|
|
p.filter_resources(Principal(role="user"), "tool", candidates)
|
|
|
|
@pytest.mark.parametrize("candidates", [None, ("bash",), "bash"])
|
|
def test_filter_non_list_candidates_raises(self, candidates):
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="candidates must be a list"):
|
|
p.filter_resources(Principal(role="user"), "tool", candidates)
|
|
|
|
|
|
# --- Request validation ---
|
|
|
|
|
|
class TestRequestValidation:
|
|
"""Malformed request identifiers must fail before any allow decision."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"roles",
|
|
[
|
|
{"user": {}},
|
|
{"user": {"tools": {"allow": "*"}}},
|
|
{"user": {"tools": {"allow": ["bash"]}}},
|
|
],
|
|
ids=["unrestricted", "wildcard", "allow-list"],
|
|
)
|
|
@pytest.mark.parametrize("target", [None, ""], ids=["null", "empty"])
|
|
def test_authorize_invalid_target_raises_for_every_policy_shape(self, roles, target):
|
|
p = _provider(roles)
|
|
with pytest.raises(ValueError, match="target must be a non-empty string"):
|
|
p.authorize(_make_request(target=target))
|
|
|
|
@pytest.mark.parametrize("resource", [None, ""], ids=["null", "empty"])
|
|
def test_authorize_invalid_resource_raises(self, resource):
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="resource must be a non-empty string"):
|
|
p.authorize(_make_request(resource=resource))
|
|
|
|
@pytest.mark.parametrize("target", [None, ""], ids=["null", "empty"])
|
|
def test_aauthorize_invalid_target_matches_sync_validation(self, target):
|
|
p = _provider({"user": {"tools": {"allow": "*"}}})
|
|
with pytest.raises(ValueError, match="target must be a non-empty string"):
|
|
asyncio.run(p.aauthorize(_make_request(target=target)))
|
|
|
|
|
|
# --- Sync / async parity ---
|
|
|
|
|
|
class TestSyncAsyncParity:
|
|
def test_aauthorize_matches_authorize(self):
|
|
p = _provider({"user": {"tools": {"allow": "*", "deny": ["bash"]}}})
|
|
req = _make_request(target="bash")
|
|
sync = p.authorize(req)
|
|
async_ = asyncio.run(p.aauthorize(req))
|
|
assert sync.allow == async_.allow
|
|
assert sync.reasons[0].code == async_.reasons[0].code
|
|
|
|
|
|
# --- Construction validation ---
|
|
|
|
|
|
class TestConstructionValidation:
|
|
"""Invalid config must fail at construction, not at request time."""
|
|
|
|
def test_unknown_provider_config_key_raises(self):
|
|
with pytest.raises(ValueError, match="unknown provider config keys.*bogus"):
|
|
RbacAuthorizationProvider(roles={"user": {}}, bogus=True)
|
|
|
|
def test_misspelled_roles_key_raises(self):
|
|
with pytest.raises(ValueError, match="unknown provider config keys.*rolez"):
|
|
RbacAuthorizationProvider(rolez={"user": {}})
|
|
|
|
def test_non_dict_roles_raises(self):
|
|
with pytest.raises(ValueError, match="roles must be a dict"):
|
|
RbacAuthorizationProvider(roles=["not", "a", "dict"])
|
|
|
|
def test_non_dict_role_config_raises(self):
|
|
with pytest.raises(ValueError, match="config must be a dict"):
|
|
RbacAuthorizationProvider(roles={"user": "not a dict"})
|
|
|
|
def test_non_dict_resource_policy_raises(self):
|
|
with pytest.raises(ValueError, match="must be a dict"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": "not a dict"}})
|
|
|
|
def test_invalid_allow_type_raises(self):
|
|
with pytest.raises(ValueError, match="allow must be"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": 42}}})
|
|
|
|
def test_invalid_allow_string_raises(self):
|
|
with pytest.raises(ValueError, match="allow string must be"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": "not_wildcard"}}})
|
|
|
|
def test_non_string_in_allow_list_raises(self):
|
|
with pytest.raises(ValueError, match="non-string"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": ["ok", 42]}}})
|
|
|
|
def test_non_string_in_deny_list_raises(self):
|
|
with pytest.raises(ValueError, match="non-string"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"deny": ["ok", None]}}})
|
|
|
|
def test_empty_string_in_allow_list_raises(self):
|
|
with pytest.raises(ValueError, match="non-string or empty"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": ["ok", ""]}}})
|
|
|
|
def test_invalid_deny_type_raises(self):
|
|
with pytest.raises(ValueError, match="deny must be"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"deny": 42}}})
|
|
|
|
def test_empty_role_name_raises(self):
|
|
with pytest.raises(ValueError, match="non-empty string"):
|
|
RbacAuthorizationProvider(roles={"": {"tools": {"allow": "*"}}})
|
|
|
|
def test_empty_resource_key_raises(self):
|
|
with pytest.raises(ValueError, match="invalid resource key"):
|
|
RbacAuthorizationProvider(roles={"user": {"": {"allow": "*"}}})
|
|
|
|
def test_explicit_null_allow_raises(self):
|
|
"""`allow: null` must NOT be treated as missing — it's a config error."""
|
|
with pytest.raises(ValueError, match="allow must not be null"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": None}}})
|
|
|
|
def test_explicit_null_deny_raises(self):
|
|
"""`deny: null` must NOT be treated as missing — it's a config error."""
|
|
with pytest.raises(ValueError, match="deny must not be null"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": "*", "deny": None}}})
|
|
|
|
def test_unknown_policy_key_raises(self):
|
|
"""Misspelled keys (e.g. 'alow') must be rejected, not silently ignored."""
|
|
with pytest.raises(ValueError, match="unknown policy keys"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"alow": ["web_search"]}}})
|
|
|
|
def test_unknown_policy_key_with_valid_keys_raises(self):
|
|
"""Unknown key alongside valid keys must still be rejected."""
|
|
with pytest.raises(ValueError, match="unknown policy keys"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {"allow": "*", "permt": ["extra"]}}})
|
|
|
|
def test_mixed_type_unknown_keys_raises_value_error(self):
|
|
"""Mixed-type unknown keys (e.g. str + int) must raise ValueError,
|
|
not TypeError from sorted() comparison failure."""
|
|
with pytest.raises(ValueError, match="unknown policy keys"):
|
|
RbacAuthorizationProvider(roles={"user": {"tools": {1: "bad", "other": "bad"}}})
|
|
|
|
|
|
# --- Config immutability ---
|
|
|
|
|
|
class TestConfigImmutability:
|
|
"""Provider must not be affected by post-construction config mutation."""
|
|
|
|
def test_mutating_config_after_construction_does_not_change_behavior(self):
|
|
roles_config = {"user": {"tools": {"allow": ["bash"]}}}
|
|
p = RbacAuthorizationProvider(roles=roles_config)
|
|
# Mutate the original config
|
|
roles_config["user"]["tools"]["allow"] = ["web_search"]
|
|
roles_config["admin"] = {"tools": {"allow": "*"}}
|
|
# Provider should still use the original compiled policy
|
|
assert p.authorize(_make_request(target="bash")).allow is True
|
|
assert p.authorize(_make_request(target="web_search")).allow is False
|
|
# Unknown role added to config should not be known to provider
|
|
with pytest.raises(ValueError, match="Unknown role"):
|
|
p.authorize(_make_request(role="admin", target="bash"))
|
|
|
|
|
|
# --- Protocol conformance ---
|
|
|
|
|
|
class TestProtocolConformance:
|
|
def test_rbac_is_authorization_provider(self):
|
|
from deerflow.authz.provider import AuthorizationProvider
|
|
|
|
assert isinstance(RbacAuthorizationProvider(roles={}), AuthorizationProvider)
|