Files
cleveragents-core/features/steps/permission_system_steps.py
Luis Mendes 64af753aab
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 19s
CI / security (pull_request) Successful in 32s
CI / typecheck (pull_request) Successful in 36s
CI / integration_tests (pull_request) Successful in 2m40s
CI / unit_tests (pull_request) Successful in 11m42s
CI / docker (pull_request) Successful in 40s
CI / benchmark-regression (pull_request) Successful in 21m35s
CI / coverage (pull_request) Successful in 45m10s
CI / lint (push) Successful in 12s
CI / quality (push) Successful in 18s
CI / build (push) Successful in 23s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 57s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m43s
CI / unit_tests (push) Successful in 12m13s
CI / benchmark-publish (push) Successful in 12m19s
CI / docker (push) Successful in 1m2s
CI / coverage (push) Successful in 1h26m6s
feat(security): add permission system
Implemented namespace/project/plan/skill permission model with role
bindings (owner/admin/editor/viewer) and default deny policy. Added
enforcement hooks at CLI/service boundaries that are server-only; local
mode returns permissive defaults. Includes role enums, permission check
service, role matrix documentation.

Includes Behave BDD scenarios, Robot integration tests, ASV benchmarks,
and reference documentation.

ISSUES CLOSED: #344
2026-02-28 20:21:18 +00:00

756 lines
24 KiB
Python

"""Step definitions for the permission system feature.
Tests the permission domain model, PermissionService in local and
server modes, the enforce_permission decorator, and role binding
management.
"""
from __future__ import annotations
import os
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.application.services.permission_service import (
PermissionService,
enforce_permission,
set_default_permission_service,
)
from cleveragents.domain.models.core.permission import (
DEFAULT_LOCAL_ROLE_MAPPING,
ROLE_PERMISSIONS,
PermissionAction,
PermissionCheck,
PermissionPolicy,
PermissionRole,
PermissionScope,
RoleBinding,
)
# ── Helpers ───────────────────────────────────────────────────────
_SERVER_MODE_ENV = "CLEVERAGENTS_SERVER_MODE"
def _set_local_mode() -> None:
os.environ.pop(_SERVER_MODE_ENV, None)
def _set_server_mode() -> None:
os.environ[_SERVER_MODE_ENV] = "true"
# ── Enum steps ────────────────────────────────────────────────────
@given("the PermissionRole enum")
def step_given_permission_role_enum(context: Context) -> None:
context.enum_values = [e.value for e in PermissionRole]
@given("the PermissionScope enum")
def step_given_permission_scope_enum(context: Context) -> None:
context.enum_values = [e.value for e in PermissionScope]
@given("the PermissionAction enum")
def step_given_permission_action_enum(context: Context) -> None:
context.enum_values = [e.value for e in PermissionAction]
@then('the enum should contain "{value}"')
def step_enum_should_contain(context: Context, value: str) -> None:
assert value in context.enum_values, f"Expected '{value}' in {context.enum_values}"
# ── ROLE_PERMISSIONS ──────────────────────────────────────────────
@given("the ROLE_PERMISSIONS constant")
def step_given_role_permissions(context: Context) -> None:
context.role_permissions = ROLE_PERMISSIONS
@then('the "{role}" role should have {count:d} allowed actions')
def step_role_has_n_actions(context: Context, role: str, count: int) -> None:
perm_role = PermissionRole(role)
actions = context.role_permissions[perm_role]
assert len(actions) == count, (
f"Expected {count} actions for {role}, got {len(actions)}"
)
# ── RoleBinding model ────────────────────────────────────────────
@given(
'a role binding for principal "{principal}" with role "{role}" '
'on "{scope}" scope "{scope_id}"'
)
def step_given_role_binding(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.binding = binding
# Also register on service if present
svc: PermissionService | None = getattr(context, "permission_service", None)
if svc is not None:
svc.add_binding(binding)
@then('the binding principal should be "{expected}"')
def step_binding_principal(context: Context, expected: str) -> None:
assert context.binding.principal == expected
@then('the binding role should be "{expected}"')
def step_binding_role(context: Context, expected: str) -> None:
assert context.binding.role == expected
@then('the binding scope should be "{expected}"')
def step_binding_scope(context: Context, expected: str) -> None:
assert context.binding.scope == expected
@then('the binding scope_id should be "{expected}"')
def step_binding_scope_id(context: Context, expected: str) -> None:
assert context.binding.scope_id == expected
# ── PermissionPolicy ─────────────────────────────────────────────
@given("a default PermissionPolicy")
def step_given_default_policy(context: Context) -> None:
context.policy = PermissionPolicy()
@given(
'a PermissionPolicy with an override for "{principal}" as "{role}" '
'on "{scope}" scope "{scope_id}"'
)
def step_given_policy_with_override(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
override = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.policy = PermissionPolicy(allow_overrides=[override])
@then("default_deny should be true")
def step_default_deny_true(context: Context) -> None:
assert context.policy.default_deny is True
@then("allow_overrides should be empty")
def step_overrides_empty(context: Context) -> None:
assert len(context.policy.allow_overrides) == 0
@then("the policy should have {count:d} allow override")
def step_policy_override_count(context: Context, count: int) -> None:
assert len(context.policy.allow_overrides) == count
# ── PermissionService — local mode ────────────────────────────────
@given("a PermissionService in local mode")
def step_given_service_local(context: Context) -> None:
_set_local_mode()
context.permission_service = PermissionService()
@given("a PermissionService in server mode with no bindings")
def step_given_service_server_no_bindings(context: Context) -> None:
_set_server_mode()
context.permission_service = PermissionService()
@given("a PermissionService in server mode")
def step_given_service_server(context: Context) -> None:
_set_server_mode()
context.permission_service = PermissionService()
@given("a PermissionService in server mode with default_deny disabled")
def step_given_service_server_no_deny(context: Context) -> None:
_set_server_mode()
policy = PermissionPolicy(default_deny=False)
context.permission_service = PermissionService(policy=policy)
@given(
'a policy override for "{principal}" as "{role}" on "{scope}" scope "{scope_id}"'
)
def step_given_policy_override_on_service(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
override = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
policy = PermissionPolicy(allow_overrides=[override])
context.permission_service = PermissionService(policy=policy)
# ── Permission check ─────────────────────────────────────────────
@when(
'I check permission for "{principal}" to "{action}" on "{scope}" scope "{scope_id}"'
)
def step_check_permission(
context: Context, principal: str, action: str, scope: str, scope_id: str
) -> None:
svc: PermissionService = context.permission_service
context.permission_result = svc.check_permission(
principal=principal,
action=PermissionAction(action),
scope=PermissionScope(scope),
scope_id=scope_id,
)
@then("the permission check result should be true")
def step_check_result_true(context: Context) -> None:
assert context.permission_result.result is True, (
f"Expected True, got False: {context.permission_result.reason}"
)
@then("the permission check result should be false")
def step_check_result_false(context: Context) -> None:
assert context.permission_result.result is False, (
f"Expected False, got True: {context.permission_result.reason}"
)
@then('the reason should contain "{fragment}"')
def step_reason_contains(context: Context, fragment: str) -> None:
assert fragment in context.permission_result.reason, (
f"Expected '{fragment}' in '{context.permission_result.reason}'"
)
# ── DEFAULT_LOCAL_ROLE_MAPPING ────────────────────────────────────
@given("the DEFAULT_LOCAL_ROLE_MAPPING constant")
def step_given_default_local_mapping(context: Context) -> None:
context.local_mapping = DEFAULT_LOCAL_ROLE_MAPPING
@then('the wildcard entry should map to "{expected}"')
def step_wildcard_maps_to(context: Context, expected: str) -> None:
assert context.local_mapping["*"] == expected
# ── PermissionCheck model ─────────────────────────────────────────
@given(
'a PermissionCheck for "{principal}" action "{action}" scope "{scope}" '
'scope_id "{scope_id}" result true reason "{reason}"'
)
def step_given_permission_check_model(
context: Context,
principal: str,
action: str,
scope: str,
scope_id: str,
reason: str,
) -> None:
context.check_model = PermissionCheck(
principal=principal,
action=PermissionAction(action),
scope=PermissionScope(scope),
scope_id=scope_id,
result=True,
reason=reason,
)
@then('the check principal should be "{expected}"')
def step_check_principal(context: Context, expected: str) -> None:
assert context.check_model.principal == expected
@then('the check action should be "{expected}"')
def step_check_action(context: Context, expected: str) -> None:
assert context.check_model.action == expected
@then('the check scope should be "{expected}"')
def step_check_scope(context: Context, expected: str) -> None:
assert context.check_model.scope == expected
@then('the check scope_id should be "{expected}"')
def step_check_scope_id(context: Context, expected: str) -> None:
assert context.check_model.scope_id == expected
@then("the check result should be true")
def step_check_model_result_true(context: Context) -> None:
assert context.check_model.result is True
@then('the check reason should be "{expected}"')
def step_check_reason(context: Context, expected: str) -> None:
assert context.check_model.reason == expected
# ── enforce_permission decorator ──────────────────────────────────
@given(
"a function decorated with enforce_permission for "
'"{action}" on "{scope}" scope "{scope_id}"'
)
def step_given_decorated_fn(
context: Context, action: str, scope: str, scope_id: str
) -> None:
context.enforce_action = action
context.enforce_scope = scope
context.enforce_scope_id = scope_id
@when("I call the decorated function in local mode")
def step_call_decorated_local(context: Context) -> None:
_set_local_mode()
@enforce_permission(
PermissionAction(context.enforce_action),
PermissionScope(context.enforce_scope),
context.enforce_scope_id,
)
def _guarded() -> str:
return "ok"
try:
context.decorated_result = _guarded()
context.decorated_error = None
except PermissionError as exc:
context.decorated_result = None
context.decorated_error = exc
@when("I call the decorated function in server mode with no bindings")
def step_call_decorated_server(context: Context) -> None:
_set_server_mode()
@enforce_permission(
PermissionAction(context.enforce_action),
PermissionScope(context.enforce_scope),
context.enforce_scope_id,
)
def _guarded() -> str:
return "ok"
try:
context.decorated_result = _guarded()
context.decorated_error = None
except PermissionError as exc:
context.decorated_result = None
context.decorated_error = exc
@then("it should succeed without raising")
def step_no_error(context: Context) -> None:
assert context.decorated_error is None
assert context.decorated_result == "ok"
@then("it should raise a PermissionError")
def step_permission_error_raised(context: Context) -> None:
assert context.decorated_error is not None
assert isinstance(context.decorated_error, PermissionError)
# ── Binding management ────────────────────────────────────────────
@given('I add a binding for "{principal}" as "{role}" on "{scope}" scope "{scope_id}"')
def step_add_binding(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.permission_service.add_binding(binding)
@when(
'I remove the binding for "{principal}" as "{role}" on "{scope}" scope "{scope_id}"'
)
def step_remove_binding(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.remove_result = context.permission_service.remove_binding(binding)
@when(
'I try to remove a binding for "{principal}" as "{role}" '
'on "{scope}" scope "{scope_id}"'
)
def step_try_remove_binding(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.remove_result = context.permission_service.remove_binding(binding)
@then("the remove result should be false")
def step_remove_result_false(context: Context) -> None:
assert context.remove_result is False
# ── get_role_bindings ─────────────────────────────────────────────
@when('I get role bindings for "{principal}" on "{scope}" scope "{scope_id}"')
def step_get_role_bindings(
context: Context, principal: str, scope: str, scope_id: str
) -> None:
svc: PermissionService = context.permission_service
context.fetched_bindings = svc.get_role_bindings(
principal=principal,
scope=PermissionScope(scope),
scope_id=scope_id,
)
@then("I should receive {count:d} binding")
def step_binding_count_singular(context: Context, count: int) -> None:
assert len(context.fetched_bindings) == count, (
f"Expected {count} bindings, got {len(context.fetched_bindings)}"
)
@then("I should receive {count:d} bindings")
def step_binding_count_plural(context: Context, count: int) -> None:
assert len(context.fetched_bindings) == count, (
f"Expected {count} bindings, got {len(context.fetched_bindings)}"
)
@then('the first binding role should be "{expected}"')
def step_first_binding_role(context: Context, expected: str) -> None:
assert context.fetched_bindings[0].role == expected
# ── Duplicate binding prevention ──────────────────────────────────
@when(
'I try to add a duplicate binding for "{principal}" as "{role}" '
'on "{scope}" scope "{scope_id}"'
)
def step_try_add_duplicate_binding(
context: Context, principal: str, role: str, scope: str, scope_id: str
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.add_result = context.permission_service.add_binding(binding)
@then("the add result should be false")
def step_add_result_false(context: Context) -> None:
assert context.add_result is False
# ── RoleBinding validation ────────────────────────────────────────
@when("I try to create a role binding with empty principal")
def step_try_create_binding_empty_principal(context: Context) -> None:
context.permission_error = None
try:
RoleBinding(
principal="",
role=PermissionRole.VIEWER,
scope=PermissionScope.PROJECT,
scope_id="proj-1",
)
except ValidationError as exc:
context.permission_error = exc
@when("I try to create a role binding with empty scope_id")
def step_try_create_binding_empty_scope_id(context: Context) -> None:
context.permission_error = None
try:
RoleBinding(
principal="alice",
role=PermissionRole.VIEWER,
scope=PermissionScope.PROJECT,
scope_id="",
)
except ValidationError as exc:
context.permission_error = exc
@then("a permission validation error should be raised")
def step_permission_validation_error(context: Context) -> None:
assert context.permission_error is not None, "Expected a ValidationError"
assert isinstance(context.permission_error, ValidationError)
# ── PermissionCheck with result=False ─────────────────────────────
@given(
'a PermissionCheck for "{principal}" action "{action}" scope "{scope}" '
'scope_id "{scope_id}" result false reason "{reason}"'
)
def step_given_permission_check_model_false(
context: Context,
principal: str,
action: str,
scope: str,
scope_id: str,
reason: str,
) -> None:
context.check_model = PermissionCheck(
principal=principal,
action=PermissionAction(action),
scope=PermissionScope(scope),
scope_id=scope_id,
result=False,
reason=reason,
)
@then("the check result should be false")
def step_check_model_result_false(context: Context) -> None:
assert context.check_model.result is False
# ── enforce_permission with pre-loaded service ────────────────────
@given(
'a pre-loaded permission service with "{principal}" as "{role}" '
'on "{scope}" scope "{scope_id}" for action "{action}"'
)
def step_given_decorated_fn_preloaded(
context: Context,
principal: str,
role: str,
scope: str,
scope_id: str,
action: str,
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
context.preloaded_service = PermissionService(bindings=[binding])
context.preloaded_principal = principal
context.enforce_action = action
context.enforce_scope = scope
context.enforce_scope_id = scope_id
@when("I call the pre-loaded decorated function in server mode")
def step_call_preloaded_decorated_server(context: Context) -> None:
_set_server_mode()
@enforce_permission(
PermissionAction(context.enforce_action),
PermissionScope(context.enforce_scope),
context.enforce_scope_id,
principal=context.preloaded_principal,
service=context.preloaded_service,
)
def _guarded() -> str:
return "ok"
try:
context.decorated_result = _guarded()
context.decorated_error = None
except PermissionError as exc:
context.decorated_result = None
context.decorated_error = exc
# ── enforce_permission with module-level default service ──────────
@given(
'a module-level default service with "{principal}" as "{role}" '
'on "{scope}" scope "{scope_id}"'
)
def step_given_module_default_service(
context: Context,
principal: str,
role: str,
scope: str,
scope_id: str,
) -> None:
binding = RoleBinding(
principal=principal,
role=PermissionRole(role),
scope=PermissionScope(scope),
scope_id=scope_id,
)
svc = PermissionService(bindings=[binding])
set_default_permission_service(svc)
context.default_svc_principal = principal
@when("I call the decorated function in server mode via module default")
def step_call_decorated_server_module_default(context: Context) -> None:
_set_server_mode()
@enforce_permission(
PermissionAction(context.enforce_action),
PermissionScope(context.enforce_scope),
context.enforce_scope_id,
principal=context.default_svc_principal,
)
def _guarded() -> str:
return "ok"
try:
context.decorated_result = _guarded()
context.decorated_error = None
except PermissionError as exc:
context.decorated_result = None
context.decorated_error = exc
finally:
# Clean up module-level default
set_default_permission_service(None)
# ── Input validation — check_permission ───────────────────────────
@when("I check permission with empty principal")
def step_check_permission_empty_principal(context: Context) -> None:
context.value_error = None
try:
context.permission_service.check_permission(
principal="",
action=PermissionAction.READ,
scope=PermissionScope.PROJECT,
scope_id="proj-1",
)
except ValueError as exc:
context.value_error = exc
@when("I check permission with whitespace-only principal")
def step_check_permission_whitespace_principal(context: Context) -> None:
context.value_error = None
try:
context.permission_service.check_permission(
principal=" ",
action=PermissionAction.READ,
scope=PermissionScope.PROJECT,
scope_id="proj-1",
)
except ValueError as exc:
context.value_error = exc
@when("I check permission with empty scope_id")
def step_check_permission_empty_scope_id(context: Context) -> None:
context.value_error = None
try:
context.permission_service.check_permission(
principal="alice",
action=PermissionAction.READ,
scope=PermissionScope.PROJECT,
scope_id="",
)
except ValueError as exc:
context.value_error = exc
@when("I check permission with whitespace-only scope_id")
def step_check_permission_whitespace_scope_id(context: Context) -> None:
context.value_error = None
try:
context.permission_service.check_permission(
principal="alice",
action=PermissionAction.READ,
scope=PermissionScope.PROJECT,
scope_id=" ",
)
except ValueError as exc:
context.value_error = exc
@then('a permission ValueError should mention "{fragment}"')
def step_permission_value_error_with_message(context: Context, fragment: str) -> None:
assert context.value_error is not None, "Expected a ValueError"
assert isinstance(context.value_error, ValueError)
assert fragment in str(context.value_error), (
f"Expected '{fragment}' in '{context.value_error}'"
)
# ── Input validation — get_role_bindings ──────────────────────────
@when("I get role bindings with empty principal")
def step_get_bindings_empty_principal(context: Context) -> None:
context.value_error = None
try:
context.permission_service.get_role_bindings(
principal="",
scope=PermissionScope.PROJECT,
scope_id="proj-1",
)
except ValueError as exc:
context.value_error = exc
@when("I get role bindings with whitespace-only scope_id")
def step_get_bindings_whitespace_scope_id(context: Context) -> None:
context.value_error = None
try:
context.permission_service.get_role_bindings(
principal="alice",
scope=PermissionScope.PROJECT,
scope_id=" ",
)
except ValueError as exc:
context.value_error = exc