From b0ab61134dc90a81e121f9471425ab91f5afae34 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 29 Mar 2026 08:38:13 +0000 Subject: [PATCH] feat(server): implement authentication, authorization, and namespace service Implement server-mode services for multi-tenant deployments: - TokenAuthClient: SHA-256 hashed bearer-token authentication with configurable TTL, register/revoke/validate operations, and constant-time comparison for timing side-channel protection - AuthorizationService: Namespace-scoped role-based access control with viewer/member/admin/owner hierarchy and grant/revoke/check_access - NamespaceService: In-memory namespace registry with list/show/members endpoints backing _cleveragents/namespace/* A2A extension methods - HealthService: Aggregated health-check probe registry returning composite healthy/unhealthy status with per-service details - DiagnosticsService: Runtime diagnostics collector (Python version, platform, uptime, loaded modules, custom checks) - Server DB tables: server_users, server_tokens (SHA-256 hashed), namespace_acls with Alembic migration s1_001 - Facade wiring: namespace/health/diagnostics handlers dispatch to real services when registered, fall back to stubs otherwise - Behave BDD: 23 scenarios covering all services and facade wiring - Robot integration: 11 test cases with helper script ISSUES CLOSED: #927 --- .../s1_001_server_auth_namespace_tables.py | 83 ++++ .../s1_002_merge_server_auth_and_m8.py | 33 ++ features/a2a_server_auth_namespace.feature | 159 +++++++ .../steps/a2a_server_auth_namespace_steps.py | 433 ++++++++++++++++++ robot/a2a_server_auth_namespace.robot | 97 ++++ robot/helper_a2a_server_auth_namespace.py | 215 +++++++++ src/cleveragents/a2a/__init__.py | 2 + src/cleveragents/a2a/clients.py | 142 +++++- src/cleveragents/a2a/facade.py | 61 ++- src/cleveragents/a2a/server/__init__.py | 27 ++ .../a2a/server/authorization_service.py | 202 ++++++++ .../a2a/server/diagnostics_service.py | 89 ++++ src/cleveragents/a2a/server/health_service.py | 101 ++++ .../a2a/server/namespace_service.py | 178 +++++++ .../infrastructure/database/models.py | 78 ++++ vulture_whitelist.py | 27 ++ 16 files changed, 1915 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/s1_001_server_auth_namespace_tables.py create mode 100644 alembic/versions/s1_002_merge_server_auth_and_m8.py create mode 100644 features/a2a_server_auth_namespace.feature create mode 100644 features/steps/a2a_server_auth_namespace_steps.py create mode 100644 robot/a2a_server_auth_namespace.robot create mode 100644 robot/helper_a2a_server_auth_namespace.py create mode 100644 src/cleveragents/a2a/server/__init__.py create mode 100644 src/cleveragents/a2a/server/authorization_service.py create mode 100644 src/cleveragents/a2a/server/diagnostics_service.py create mode 100644 src/cleveragents/a2a/server/health_service.py create mode 100644 src/cleveragents/a2a/server/namespace_service.py diff --git a/alembic/versions/s1_001_server_auth_namespace_tables.py b/alembic/versions/s1_001_server_auth_namespace_tables.py new file mode 100644 index 000000000..f67f881bf --- /dev/null +++ b/alembic/versions/s1_001_server_auth_namespace_tables.py @@ -0,0 +1,83 @@ +"""Add server_users, server_tokens, and namespace_acls tables. + +Revision ID: s1_001_server_auth_namespace +Revises: m4_003_plan_env_columns +Create Date: 2026-03-29 00:00:00 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "s1_001_server_auth_namespace" +down_revision: str | Sequence[str] | None = "m4_003_plan_env_columns" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Create the server authentication and namespace tables.""" + # -- server_users --------------------------------------------------------- + op.create_table( + "server_users", + sa.Column("user_id", sa.String(26), primary_key=True), + sa.Column("username", sa.String(128), nullable=False, unique=True), + sa.Column("display_name", sa.String(256), nullable=False, server_default=""), + sa.Column("email", sa.String(320), nullable=False, server_default=""), + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + server_default=sa.text("1"), + ), + sa.Column("created_at", sa.String(40), nullable=False), + sa.Column("updated_at", sa.String(40), nullable=False), + ) + op.create_index("ix_server_users_username", "server_users", ["username"]) + op.create_index("ix_server_users_email", "server_users", ["email"]) + + # -- server_tokens -------------------------------------------------------- + op.create_table( + "server_tokens", + sa.Column("token_hash", sa.String(64), primary_key=True), + sa.Column( + "user_id", + sa.String(26), + sa.ForeignKey("server_users.user_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("label", sa.String(128), nullable=False, server_default=""), + sa.Column("expires_at", sa.String(40), nullable=False), + sa.Column("created_at", sa.String(40), nullable=False), + ) + op.create_index("ix_server_tokens_user_id", "server_tokens", ["user_id"]) + op.create_index("ix_server_tokens_expires_at", "server_tokens", ["expires_at"]) + + # -- namespace_acls ------------------------------------------------------- + op.create_table( + "namespace_acls", + sa.Column( + "user_id", + sa.String(26), + sa.ForeignKey("server_users.user_id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("namespace", sa.String(128), primary_key=True), + sa.Column("role", sa.String(20), nullable=False), + sa.Column("granted_at", sa.String(40), nullable=False), + sa.CheckConstraint( + "role IN ('viewer', 'member', 'admin', 'owner')", + name="ck_namespace_acls_role", + ), + ) + op.create_index("ix_namespace_acls_namespace", "namespace_acls", ["namespace"]) + + +def downgrade() -> None: + """Drop the server authentication and namespace tables.""" + op.drop_table("namespace_acls") + op.drop_table("server_tokens") + op.drop_table("server_users") diff --git a/alembic/versions/s1_002_merge_server_auth_and_m8.py b/alembic/versions/s1_002_merge_server_auth_and_m8.py new file mode 100644 index 000000000..1cb1775f4 --- /dev/null +++ b/alembic/versions/s1_002_merge_server_auth_and_m8.py @@ -0,0 +1,33 @@ +"""Merge server-auth-namespace branch with m8 main branch. + +m8_002_merge_profile_rename_and_corrections and +s1_001_server_auth_namespace both exist as separate heads. +This no-op merge migration resolves them into a single head. + +Revision ID: s1_002_merge_server_auth_and_m8 +Revises: m8_002_merge_profile_rename_and_corrections, +s1_001_server_auth_namespace +Create Date: 2026-04-02 00:00:00 + +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "s1_002_merge_server_auth_and_m8" +down_revision: str | Sequence[str] | None = ( + "m8_002_merge_profile_rename_and_corrections", + "s1_001_server_auth_namespace", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/features/a2a_server_auth_namespace.feature b/features/a2a_server_auth_namespace.feature new file mode 100644 index 000000000..66ff3dea1 --- /dev/null +++ b/features/a2a_server_auth_namespace.feature @@ -0,0 +1,159 @@ +@phase2 @a2a @server +Feature: Server-mode authentication, authorization, and namespace service + As a server operator + I want token-based auth, namespace-scoped authorization, and namespace queries + So that multi-tenant server deployments are secure and queryable + + # ----------------------------------------------------------------------- + # TokenAuthClient — authentication + # ----------------------------------------------------------------------- + + Scenario: TokenAuthClient authenticates a valid token + Given a TokenAuthClient with default TTL + And a registered bearer token "tok_server_test_01" + When I authenticate with token "tok_server_test_01" + Then authentication should succeed + + Scenario: TokenAuthClient rejects an unknown token + Given a TokenAuthClient with default TTL + When I authenticate with token "tok_unknown_999" + Then authentication should fail + + Scenario: TokenAuthClient rejects an expired token + Given a TokenAuthClient with TTL of 1 second + And a registered bearer token "tok_expiring" + And I wait for the token to expire + When I authenticate with token "tok_expiring" + Then authentication should fail + + Scenario: TokenAuthClient validates a registered token + Given a TokenAuthClient with default TTL + And a registered bearer token "tok_validate_test" + When I validate token "tok_validate_test" + Then token validation should succeed + + Scenario: TokenAuthClient revokes a token + Given a TokenAuthClient with default TTL + And a registered bearer token "tok_to_revoke" + When I revoke token "tok_to_revoke" + Then the revocation should succeed + And authenticating with revoked token "tok_to_revoke" should fail + + Scenario: TokenAuthClient rejects empty token in authenticate + Given a TokenAuthClient with default TTL + When I authenticate with an empty token + Then a ValueError should be raised for empty token in server auth + + Scenario: TokenAuthClient reports active token count + Given a TokenAuthClient with default TTL + And a registered bearer token "tok_count_1" + And a registered bearer token "tok_count_2" + Then active token count should be 2 + + # ----------------------------------------------------------------------- + # AuthorizationService — namespace-scoped access + # ----------------------------------------------------------------------- + + Scenario: AuthorizationService grants and checks access + Given an AuthorizationService + And user "u-1" is granted "admin" role in namespace "default" + When I check "write" access for "u-1" in "default" + Then access check should pass + + Scenario: AuthorizationService denies insufficient role + Given an AuthorizationService + And user "u-2" is granted "viewer" role in namespace "proj-a" + When I check "write" access for "u-2" in "proj-a" + Then an AuthorizationError should be raised + + Scenario: AuthorizationService denies unknown user + Given an AuthorizationService + When I check "read" access for "u-unknown" in "default" + Then an AuthorizationError should be raised + + Scenario: AuthorizationService revokes access + Given an AuthorizationService + And user "u-3" is granted "member" role in namespace "ns-x" + When I revoke access for "u-3" in "ns-x" + Then the revocation should report success + And checking "read" access for "u-3" in "ns-x" should raise AuthorizationError + + Scenario: AuthorizationService lists user grants + Given an AuthorizationService + And user "u-4" is granted "viewer" role in namespace "ns-a" + And user "u-4" is granted "admin" role in namespace "ns-b" + When I list grants for "u-4" + Then the grants list should contain 2 entries + + # ----------------------------------------------------------------------- + # NamespaceService — list / show / members + # ----------------------------------------------------------------------- + + Scenario: NamespaceService lists registered namespaces + Given a NamespaceService with namespaces "default" and "staging" + When I list namespaces + Then the namespace list should contain 2 entries + + Scenario: NamespaceService shows namespace details + Given a NamespaceService with namespace "production" owned by "u-owner" + When I show namespace "production" + Then the namespace details should include owner "u-owner" + + Scenario: NamespaceService returns members of a namespace + Given a NamespaceService with namespace "team-ns" having 3 members + When I list members of namespace "team-ns" + Then the members list should contain 3 entries + + Scenario: NamespaceService raises on unknown namespace show + Given a NamespaceService with namespace "only-one" + When I show namespace "nonexistent" + Then a ResourceNotFoundError should be raised + + Scenario: NamespaceService raises on unknown namespace members + Given a NamespaceService with namespace "only-one" + When I list members of namespace "nonexistent" + Then a ResourceNotFoundError should be raised + + # ----------------------------------------------------------------------- + # HealthService — health check aggregation + # ----------------------------------------------------------------------- + + Scenario: HealthService returns healthy when no probes fail + Given a HealthService with a healthy probe + When I run the health check + Then the overall status should be "healthy" + + Scenario: HealthService returns unhealthy when a probe fails + Given a HealthService with an unhealthy probe + When I run the health check + Then the overall status should be "unhealthy" + + # ----------------------------------------------------------------------- + # DiagnosticsService — runtime diagnostics + # ----------------------------------------------------------------------- + + Scenario: DiagnosticsService returns runtime info + Given a DiagnosticsService + When I run diagnostics + Then the diagnostics result should include python version + + # ----------------------------------------------------------------------- + # Facade wiring — namespace handlers use real services + # ----------------------------------------------------------------------- + + Scenario: Facade dispatches namespace list to NamespaceService + Given a facade wired with a NamespaceService containing "default" + When I dispatch "_cleveragents/namespace/list" + Then the response should contain 1 namespace + + Scenario: Facade dispatches health check to HealthService + Given a facade wired with a HealthService + When I dispatch "_cleveragents/health/check" + Then the server facade response status should be "ok" + And the server response data status should be "healthy" + + Scenario: Facade dispatches diagnostics run to DiagnosticsService + Given a facade wired with a DiagnosticsService + When I dispatch "_cleveragents/diagnostics/run" + Then the server facade response status should be "ok" + And the server response data should include python info diff --git a/features/steps/a2a_server_auth_namespace_steps.py b/features/steps/a2a_server_auth_namespace_steps.py new file mode 100644 index 000000000..d881e4051 --- /dev/null +++ b/features/steps/a2a_server_auth_namespace_steps.py @@ -0,0 +1,433 @@ +"""Step definitions for server-mode auth, authorization, and namespace scenarios. + +Covers: +- TokenAuthClient: register, authenticate, validate, revoke, expiry +- AuthorizationService: grant, check_access, revoke, list_grants +- NamespaceService: list, show, members +- HealthService: check +- DiagnosticsService: run +- Facade wiring: namespace/health/diagnostics dispatch +""" + +from __future__ import annotations + +import time + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.a2a.clients import TokenAuthClient +from cleveragents.a2a.facade import A2aLocalFacade +from cleveragents.a2a.models import A2aRequest +from cleveragents.a2a.server.authorization_service import AuthorizationService +from cleveragents.a2a.server.diagnostics_service import DiagnosticsService +from cleveragents.a2a.server.health_service import HealthService +from cleveragents.a2a.server.namespace_service import ( + NamespaceMember, + NamespaceRecord, + NamespaceService, +) +from cleveragents.core.exceptions import AuthorizationError, ResourceNotFoundError + +# ----------------------------------------------------------------------- +# TokenAuthClient steps +# ----------------------------------------------------------------------- + + +@given("a TokenAuthClient with default TTL") +def step_token_auth_default(context: Context) -> None: + context.auth_client = TokenAuthClient() + context.auth_result = None + context.auth_error = None + + +@given("a TokenAuthClient with TTL of 1 second") +def step_token_auth_short_ttl(context: Context) -> None: + context.auth_client = TokenAuthClient(token_ttl=1) + context.auth_result = None + context.auth_error = None + + +@given('a registered bearer token "{token}"') +def step_register_token(context: Context, token: str) -> None: + context.auth_client.register_token(token) + + +@given("I wait for the token to expire") +def step_wait_expiry(context: Context) -> None: + # The test environment caps time.sleep() to 10ms, so instead of + # sleeping we directly backdate the token expiry in the internal store. + client = context.auth_client + for key in client._tokens: + client._tokens[key] = time.time() - 10 # already expired + + +@when('I authenticate with token "{token}"') +def step_authenticate(context: Context, token: str) -> None: + context.auth_result = context.auth_client.authenticate(token) + + +@when("I authenticate with an empty token") +def step_authenticate_empty(context: Context) -> None: + try: + context.auth_client.authenticate("") + except ValueError as exc: + context.auth_error = exc + + +@when('I validate token "{token}"') +def step_validate(context: Context, token: str) -> None: + context.auth_result = context.auth_client.validate_token(token) + + +@when('I revoke token "{token}"') +def step_revoke_token(context: Context, token: str) -> None: + context.auth_result = context.auth_client.revoke_token(token) + + +@then("authentication should succeed") +def step_auth_succeeds(context: Context) -> None: + assert context.auth_result is True, ( + f"Expected auth to succeed, got {context.auth_result}" + ) + + +@then("authentication should fail") +def step_auth_fails(context: Context) -> None: + assert context.auth_result is False, ( + f"Expected auth to fail, got {context.auth_result}" + ) + + +@then("token validation should succeed") +def step_validation_succeeds(context: Context) -> None: + assert context.auth_result is True, ( + f"Expected validation to succeed, got {context.auth_result}" + ) + + +@then("the revocation should succeed") +def step_revocation_succeeds(context: Context) -> None: + assert context.auth_result is True, ( + f"Expected revocation to succeed, got {context.auth_result}" + ) + + +@then('authenticating with revoked token "{token}" should fail') +def step_auth_revoked(context: Context, token: str) -> None: + result = context.auth_client.authenticate(token) + assert result is False, f"Expected auth to fail after revocation, got {result}" + + +@then("a ValueError should be raised for empty token in server auth") +def step_value_error_empty(context: Context) -> None: + assert context.auth_error is not None, "Expected ValueError but none raised" + assert isinstance(context.auth_error, ValueError) + + +@then("active token count should be {count:d}") +def step_active_count(context: Context, count: int) -> None: + actual = context.auth_client.active_token_count + assert actual == count, f"Expected {count} active tokens, got {actual}" + + +# ----------------------------------------------------------------------- +# AuthorizationService steps +# ----------------------------------------------------------------------- + + +@given("an AuthorizationService") +def step_authz_service(context: Context) -> None: + context.authz = AuthorizationService() + context.authz_result = None + context.authz_error = None + + +@given('user "{uid}" is granted "{role}" role in namespace "{ns}"') +def step_grant(context: Context, uid: str, role: str, ns: str) -> None: + context.authz.grant(uid, ns, role) + + +@when('I check "{op}" access for "{uid}" in "{ns}"') +def step_check_access(context: Context, op: str, uid: str, ns: str) -> None: + try: + context.authz_result = context.authz.check_access(uid, ns, op) + context.authz_error = None + except AuthorizationError as exc: + context.authz_error = exc + context.authz_result = None + + +@when('I revoke access for "{uid}" in "{ns}"') +def step_revoke_access(context: Context, uid: str, ns: str) -> None: + context.authz_result = context.authz.revoke(uid, ns) + + +@when('I list grants for "{uid}"') +def step_list_grants(context: Context, uid: str) -> None: + context.authz_result = context.authz.list_grants(uid) + + +@then("access check should pass") +def step_access_passes(context: Context) -> None: + assert context.authz_result is True, ( + f"Expected access to pass, got {context.authz_result}" + ) + + +@then("an AuthorizationError should be raised") +def step_authz_error(context: Context) -> None: + assert context.authz_error is not None, ( + "Expected AuthorizationError but none raised" + ) + assert isinstance(context.authz_error, AuthorizationError) + + +@then("the revocation should report success") +def step_revocation_report(context: Context) -> None: + assert context.authz_result is True, ( + f"Expected revocation to succeed, got {context.authz_result}" + ) + + +@then('checking "{op}" access for "{uid}" in "{ns}" should raise AuthorizationError') +def step_post_revoke_check(context: Context, op: str, uid: str, ns: str) -> None: + try: + context.authz.check_access(uid, ns, op) + raise AssertionError("Expected AuthorizationError but none raised") + except AuthorizationError: + pass + + +@then("the grants list should contain {count:d} entries") +def step_grants_count(context: Context, count: int) -> None: + assert isinstance(context.authz_result, list) + assert len(context.authz_result) == count, ( + f"Expected {count} grants, got {len(context.authz_result)}" + ) + + +# ----------------------------------------------------------------------- +# NamespaceService steps +# ----------------------------------------------------------------------- + + +@given('a NamespaceService with namespaces "{ns1}" and "{ns2}"') +def step_ns_service_two(context: Context, ns1: str, ns2: str) -> None: + context.ns_svc = NamespaceService() + context.ns_svc.register_namespace(NamespaceRecord(namespace_id="ns-1", name=ns1)) + context.ns_svc.register_namespace(NamespaceRecord(namespace_id="ns-2", name=ns2)) + context.ns_result = None + context.ns_error = None + + +@given('a NamespaceService with namespace "{ns}" owned by "{owner}"') +def step_ns_service_owned(context: Context, ns: str, owner: str) -> None: + context.ns_svc = NamespaceService() + context.ns_svc.register_namespace( + NamespaceRecord(namespace_id="ns-1", name=ns, owner_id=owner) + ) + context.ns_result = None + context.ns_error = None + + +@given('a NamespaceService with namespace "{ns}" having {count:d} members') +def step_ns_service_members(context: Context, ns: str, count: int) -> None: + context.ns_svc = NamespaceService() + members = [ + NamespaceMember(user_id=f"u-{i}", username=f"user{i}", role="member") + for i in range(count) + ] + context.ns_svc.register_namespace( + NamespaceRecord(namespace_id="ns-1", name=ns, members=members) + ) + context.ns_result = None + context.ns_error = None + + +@given('a NamespaceService with namespace "{ns}"') +def step_ns_service_single(context: Context, ns: str) -> None: + context.ns_svc = NamespaceService() + context.ns_svc.register_namespace(NamespaceRecord(namespace_id="ns-1", name=ns)) + context.ns_result = None + context.ns_error = None + + +@when("I list namespaces") +def step_list_ns(context: Context) -> None: + context.ns_result = context.ns_svc.list_namespaces() + + +@when('I show namespace "{ns}"') +def step_show_ns(context: Context, ns: str) -> None: + try: + context.ns_result = context.ns_svc.show(ns) + context.ns_error = None + except ResourceNotFoundError as exc: + context.ns_error = exc + + +@when('I list members of namespace "{ns}"') +def step_members_ns(context: Context, ns: str) -> None: + try: + context.ns_result = context.ns_svc.members(ns) + context.ns_error = None + except ResourceNotFoundError as exc: + context.ns_error = exc + + +@then("the namespace list should contain {count:d} entries") +def step_ns_list_count(context: Context, count: int) -> None: + assert isinstance(context.ns_result, list) + assert len(context.ns_result) == count, ( + f"Expected {count} namespaces, got {len(context.ns_result)}" + ) + + +@then('the namespace details should include owner "{owner}"') +def step_ns_owner(context: Context, owner: str) -> None: + assert isinstance(context.ns_result, dict) + assert context.ns_result.get("owner_id") == owner, ( + f"Expected owner {owner!r}, got {context.ns_result.get('owner_id')!r}" + ) + + +@then("the members list should contain {count:d} entries") +def step_members_count(context: Context, count: int) -> None: + assert isinstance(context.ns_result, list) + assert len(context.ns_result) == count, ( + f"Expected {count} members, got {len(context.ns_result)}" + ) + + +@then("a ResourceNotFoundError should be raised") +def step_not_found_error(context: Context) -> None: + assert context.ns_error is not None, ( + "Expected ResourceNotFoundError but none raised" + ) + assert isinstance(context.ns_error, ResourceNotFoundError) + + +# ----------------------------------------------------------------------- +# HealthService steps +# ----------------------------------------------------------------------- + + +class _HealthyProbe: + def health_status(self) -> dict[str, str]: + return {"status": "healthy"} + + +class _UnhealthyProbe: + def health_status(self) -> dict[str, str]: + return {"status": "unhealthy", "reason": "test failure"} + + +@given("a HealthService with a healthy probe") +def step_health_healthy(context: Context) -> None: + context.health_svc = HealthService() + context.health_svc.register("test_probe", _HealthyProbe()) + context.health_result = None + + +@given("a HealthService with an unhealthy probe") +def step_health_unhealthy(context: Context) -> None: + context.health_svc = HealthService() + context.health_svc.register("test_probe", _UnhealthyProbe()) + context.health_result = None + + +@when("I run the health check") +def step_run_health(context: Context) -> None: + context.health_result = context.health_svc.check() + + +@then('the overall status should be "{status}"') +def step_health_status(context: Context, status: str) -> None: + assert context.health_result["status"] == status, ( + f"Expected {status!r}, got {context.health_result['status']!r}" + ) + + +# ----------------------------------------------------------------------- +# DiagnosticsService steps +# ----------------------------------------------------------------------- + + +@given("a DiagnosticsService") +def step_diag_service(context: Context) -> None: + context.diag_svc = DiagnosticsService() + context.diag_result = None + + +@when("I run diagnostics") +def step_run_diag(context: Context) -> None: + context.diag_result = context.diag_svc.run() + + +@then("the diagnostics result should include python version") +def step_diag_python(context: Context) -> None: + assert "python" in context.diag_result + assert "version" in context.diag_result["python"] + + +# ----------------------------------------------------------------------- +# Facade wiring steps +# ----------------------------------------------------------------------- + + +@given('a facade wired with a NamespaceService containing "{ns}"') +def step_facade_ns(context: Context, ns: str) -> None: + ns_svc = NamespaceService() + ns_svc.register_namespace(NamespaceRecord(namespace_id="ns-1", name=ns)) + context.facade = A2aLocalFacade({"namespace_service": ns_svc}) + context.facade_response = None + + +@given("a facade wired with a HealthService") +def step_facade_health(context: Context) -> None: + health_svc = HealthService() + health_svc.register("test", _HealthyProbe()) + context.facade = A2aLocalFacade({"health_service": health_svc}) + context.facade_response = None + + +@given("a facade wired with a DiagnosticsService") +def step_facade_diag(context: Context) -> None: + context.facade = A2aLocalFacade({"diagnostics_service": DiagnosticsService()}) + context.facade_response = None + + +@when('I dispatch "{operation}"') +def step_dispatch(context: Context, operation: str) -> None: + request = A2aRequest(operation=operation) + context.facade_response = context.facade.dispatch(request) + + +@then("the response should contain {count:d} namespace") +def step_response_ns_count(context: Context, count: int) -> None: + ns_list = context.facade_response.data.get("namespaces", []) + assert len(ns_list) == count, f"Expected {count} namespace(s), got {len(ns_list)}" + + +@then('the server facade response status should be "{status}"') +def step_server_response_status(context: Context, status: str) -> None: + assert context.facade_response.status == status, ( + f"Expected {status!r}, got {context.facade_response.status!r}" + ) + + +@then('the server response data status should be "{status}"') +def step_server_response_data_status(context: Context, status: str) -> None: + assert context.facade_response.data.get("status") == status, ( + f"Expected data status {status!r}, got " + f"{context.facade_response.data.get('status')!r}" + ) + + +@then("the server response data should include python info") +def step_server_response_python(context: Context) -> None: + assert "python" in context.facade_response.data, ( + f"Expected 'python' in data, got keys: " + f"{list(context.facade_response.data.keys())}" + ) diff --git a/robot/a2a_server_auth_namespace.robot b/robot/a2a_server_auth_namespace.robot new file mode 100644 index 000000000..86f2c082e --- /dev/null +++ b/robot/a2a_server_auth_namespace.robot @@ -0,0 +1,97 @@ +*** Settings *** +Documentation Integration tests for server-mode auth, namespace, and health services +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_a2a_server_auth_namespace.py + +*** Test Cases *** +TokenAuthClient Authenticate Valid Token + [Documentation] Verify TokenAuthClient authenticates a registered token + ${result}= Run Process ${PYTHON} ${HELPER} token-auth-valid cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} token-auth-valid-ok + +TokenAuthClient Reject Unknown Token + [Documentation] Verify TokenAuthClient rejects an unregistered token + ${result}= Run Process ${PYTHON} ${HELPER} token-auth-unknown cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} token-auth-unknown-ok + +TokenAuthClient Revoke Token + [Documentation] Verify TokenAuthClient revokes a token successfully + ${result}= Run Process ${PYTHON} ${HELPER} token-revoke cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} token-revoke-ok + +AuthorizationService Grant And Check + [Documentation] Verify grant and check_access work together + ${result}= Run Process ${PYTHON} ${HELPER} authz-grant-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} authz-grant-check-ok + +AuthorizationService Deny Insufficient Role + [Documentation] Verify check_access raises on insufficient role + ${result}= Run Process ${PYTHON} ${HELPER} authz-deny cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} authz-deny-ok + +NamespaceService List And Show + [Documentation] Verify namespace list and show operations + ${result}= Run Process ${PYTHON} ${HELPER} namespace-list-show cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} namespace-list-show-ok + +NamespaceService Members Query + [Documentation] Verify namespace members query + ${result}= Run Process ${PYTHON} ${HELPER} namespace-members cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} namespace-members-ok + +HealthService Aggregated Check + [Documentation] Verify health service returns aggregated status + ${result}= Run Process ${PYTHON} ${HELPER} health-check cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} health-check-ok + +DiagnosticsService Runtime Info + [Documentation] Verify diagnostics service returns runtime info + ${result}= Run Process ${PYTHON} ${HELPER} diagnostics-run cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} diagnostics-run-ok + +Facade Wired Namespace Dispatch + [Documentation] Verify facade dispatches namespace ops to real service + ${result}= Run Process ${PYTHON} ${HELPER} facade-namespace cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} facade-namespace-ok + +Facade Wired Health Dispatch + [Documentation] Verify facade dispatches health check to real service + ${result}= Run Process ${PYTHON} ${HELPER} facade-health cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} facade-health-ok diff --git a/robot/helper_a2a_server_auth_namespace.py b/robot/helper_a2a_server_auth_namespace.py new file mode 100644 index 000000000..853aa0ef5 --- /dev/null +++ b/robot/helper_a2a_server_auth_namespace.py @@ -0,0 +1,215 @@ +"""Helper script for a2a_server_auth_namespace.robot integration tests. + +Each subcommand is a self-contained check that prints a sentinel on success. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure local source tree is importable +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.a2a.clients import TokenAuthClient # noqa: E402 +from cleveragents.a2a.facade import A2aLocalFacade # noqa: E402 +from cleveragents.a2a.models import A2aRequest # noqa: E402 +from cleveragents.a2a.server.authorization_service import ( # noqa: E402 + AuthorizationService, +) +from cleveragents.a2a.server.diagnostics_service import ( # noqa: E402 + DiagnosticsService, +) +from cleveragents.a2a.server.health_service import HealthService # noqa: E402 +from cleveragents.a2a.server.namespace_service import ( # noqa: E402 + NamespaceMember, + NamespaceRecord, + NamespaceService, +) +from cleveragents.core.exceptions import AuthorizationError # noqa: E402 + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def token_auth_valid() -> None: + """Register and authenticate a token.""" + client = TokenAuthClient() + client.register_token("tok_robot_01") + if client.authenticate("tok_robot_01"): + print("token-auth-valid-ok") + else: + print("FAIL: auth should succeed", file=sys.stderr) + sys.exit(1) + + +def token_auth_unknown() -> None: + """Attempt to auth with an unregistered token.""" + client = TokenAuthClient() + if not client.authenticate("tok_unknown_xyz"): + print("token-auth-unknown-ok") + else: + print("FAIL: auth should fail for unknown token", file=sys.stderr) + sys.exit(1) + + +def token_revoke() -> None: + """Register then revoke a token.""" + client = TokenAuthClient() + client.register_token("tok_revoke_me") + revoked = client.revoke_token("tok_revoke_me") + still_valid = client.authenticate("tok_revoke_me") + if revoked and not still_valid: + print("token-revoke-ok") + else: + print("FAIL: revocation workflow failed", file=sys.stderr) + sys.exit(1) + + +def authz_grant_check() -> None: + """Grant admin role and verify write access passes.""" + authz = AuthorizationService() + authz.grant("u-1", "default", "admin") + result = authz.check_access("u-1", "default", "write") + if result: + print("authz-grant-check-ok") + else: + print("FAIL: access check should pass", file=sys.stderr) + sys.exit(1) + + +def authz_deny() -> None: + """Verify viewer cannot write.""" + authz = AuthorizationService() + authz.grant("u-2", "ns-x", "viewer") + try: + authz.check_access("u-2", "ns-x", "write") + print("FAIL: should have raised AuthorizationError", file=sys.stderr) + sys.exit(1) + except AuthorizationError: + print("authz-deny-ok") + + +def namespace_list_show() -> None: + """Register namespaces and verify list + show.""" + svc = NamespaceService() + svc.register_namespace( + NamespaceRecord(namespace_id="ns-1", name="default", owner_id="u-owner") + ) + svc.register_namespace(NamespaceRecord(namespace_id="ns-2", name="staging")) + ns_list = svc.list_namespaces() + detail = svc.show("default") + if len(ns_list) == 2 and detail["owner_id"] == "u-owner": + print("namespace-list-show-ok") + else: + print(f"FAIL: list={ns_list}, detail={detail}", file=sys.stderr) + sys.exit(1) + + +def namespace_members() -> None: + """Register namespace with members and query.""" + svc = NamespaceService() + members = [ + NamespaceMember(user_id=f"u-{i}", username=f"user{i}", role="member") + for i in range(3) + ] + svc.register_namespace( + NamespaceRecord(namespace_id="ns-1", name="team", members=members) + ) + result = svc.members("team") + if len(result) == 3: + print("namespace-members-ok") + else: + print(f"FAIL: expected 3 members, got {len(result)}", file=sys.stderr) + sys.exit(1) + + +class _HealthyProbe: + def health_status(self) -> dict[str, str]: + return {"status": "healthy"} + + +def health_check() -> None: + """Verify health service aggregation.""" + svc = HealthService() + svc.register("test", _HealthyProbe()) + result = svc.check() + if result["status"] == "healthy" and "uptime_seconds" in result: + print("health-check-ok") + else: + print(f"FAIL: unexpected result {result}", file=sys.stderr) + sys.exit(1) + + +def diagnostics_run() -> None: + """Verify diagnostics collection.""" + svc = DiagnosticsService() + result = svc.run() + if result["status"] == "ok" and "python" in result: + print("diagnostics-run-ok") + else: + print(f"FAIL: unexpected result {result}", file=sys.stderr) + sys.exit(1) + + +def facade_namespace() -> None: + """Verify facade dispatches namespace list to real service.""" + ns_svc = NamespaceService() + ns_svc.register_namespace(NamespaceRecord(namespace_id="ns-1", name="default")) + facade = A2aLocalFacade({"namespace_service": ns_svc}) + request = A2aRequest(operation="_cleveragents/namespace/list") + response = facade.dispatch(request) + ns_list = response.data.get("namespaces", []) + if response.status == "ok" and len(ns_list) == 1: + print("facade-namespace-ok") + else: + print(f"FAIL: response={response}", file=sys.stderr) + sys.exit(1) + + +def facade_health() -> None: + """Verify facade dispatches health check to real service.""" + health_svc = HealthService() + health_svc.register("test", _HealthyProbe()) + facade = A2aLocalFacade({"health_service": health_svc}) + request = A2aRequest(operation="_cleveragents/health/check") + response = facade.dispatch(request) + if response.status == "ok" and response.data.get("status") == "healthy": + print("facade-health-ok") + else: + print(f"FAIL: response={response}", file=sys.stderr) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# CLI dispatch +# --------------------------------------------------------------------------- + +_COMMANDS = { + "token-auth-valid": token_auth_valid, + "token-auth-unknown": token_auth_unknown, + "token-revoke": token_revoke, + "authz-grant-check": authz_grant_check, + "authz-deny": authz_deny, + "namespace-list-show": namespace_list_show, + "namespace-members": namespace_members, + "health-check": health_check, + "diagnostics-run": diagnostics_run, + "facade-namespace": facade_namespace, + "facade-health": facade_health, +} + + +def main() -> None: + """Dispatch to the appropriate subcommand.""" + if len(sys.argv) < 2 or sys.argv[1] not in _COMMANDS: + print(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>", file=sys.stderr) + sys.exit(2) + _COMMANDS[sys.argv[1]]() + + +if __name__ == "__main__": + main() diff --git a/src/cleveragents/a2a/__init__.py b/src/cleveragents/a2a/__init__.py index a2db998f2..a9e546266 100644 --- a/src/cleveragents/a2a/__init__.py +++ b/src/cleveragents/a2a/__init__.py @@ -25,6 +25,7 @@ from cleveragents.a2a.clients import ( StubAuthClient, StubRemoteExecutionClient, StubServerClient, + TokenAuthClient, ) from cleveragents.a2a.errors import ( A2aError, @@ -66,4 +67,5 @@ __all__ = [ "StubAuthClient", "StubRemoteExecutionClient", "StubServerClient", + "TokenAuthClient", ] diff --git a/src/cleveragents/a2a/clients.py b/src/cleveragents/a2a/clients.py index d6a02bdaf..119be1269 100644 --- a/src/cleveragents/a2a/clients.py +++ b/src/cleveragents/a2a/clients.py @@ -1,15 +1,23 @@ """Server client protocol stubs for remote server communication. Defines three Protocol interfaces (``ServerClient``, ``RemoteExecutionClient``, -``AuthClient``) and their stub implementations that raise -:class:`NotImplementedError`. When server mode is implemented as a separate -project the concrete clients will replace these stubs. +``AuthClient``), their stub implementations that raise +:class:`NotImplementedError`, and the concrete :class:`TokenAuthClient` which +validates bearer tokens against an in-memory store. The +:class:`TokenAuthClient` replaces :class:`StubAuthClient` for server-mode +deployments. """ from __future__ import annotations +import hashlib +import time from typing import Any, Protocol, runtime_checkable +import structlog + +_logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + # --------------------------------------------------------------------------- # Protocol definitions # --------------------------------------------------------------------------- @@ -187,6 +195,133 @@ class StubAuthClient: raise NotImplementedError(_STUB_MSG) +# --------------------------------------------------------------------------- +# Concrete auth implementation — token-based bearer authentication +# --------------------------------------------------------------------------- + +# Default token lifetime: 24 hours (in seconds). +_DEFAULT_TOKEN_TTL: int = 86400 + + +def _hash_token(token: str) -> str: + """Return a SHA-256 hex digest of *token*. + + Tokens are stored hashed so that the in-memory store never holds + plaintext credentials. + """ + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +class TokenAuthClient: + """Token-based :class:`AuthClient` implementation for server mode. + + Maintains an in-memory mapping from token hashes to expiry timestamps. + Use :meth:`register_token` to provision tokens (e.g. from a database + seeder or admin endpoint) and :meth:`authenticate` / :meth:`validate_token` + to verify them. + + Args: + token_ttl: Lifetime in seconds for newly registered tokens. + Defaults to ``86400`` (24 hours). + """ + + def __init__(self, token_ttl: int = _DEFAULT_TOKEN_TTL) -> None: + if token_ttl <= 0: + raise ValueError("token_ttl must be a positive integer") + self._token_ttl: int = token_ttl + # Mapping: SHA-256(token) -> expiry_epoch + self._tokens: dict[str, float] = {} + + # -- public management API ------------------------------------------------ + + def register_token(self, token: str, ttl: int | None = None) -> None: + """Register a bearer token with an optional custom TTL. + + Args: + token: The plaintext bearer token. + ttl: Per-token override of :attr:`token_ttl`; ``None`` uses the + instance default. + + Raises: + ValueError: If *token* is empty or not a string. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be a non-empty string") + effective_ttl = ttl if ttl is not None else self._token_ttl + hashed = _hash_token(token) + self._tokens[hashed] = time.time() + effective_ttl + _logger.debug( + "token.registered", + token_hash=hashed[:12], + ttl=effective_ttl, + ) + + def revoke_token(self, token: str) -> bool: + """Revoke a previously registered token. + + Args: + token: The plaintext bearer token. + + Returns: + ``True`` if the token was found and removed; ``False`` otherwise. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be a non-empty string") + hashed = _hash_token(token) + removed = self._tokens.pop(hashed, None) is not None + if removed: + _logger.debug("token.revoked", token_hash=hashed[:12]) + return removed + + @property + def active_token_count(self) -> int: + """Return the number of non-expired tokens currently registered.""" + now = time.time() + return sum(1 for exp in self._tokens.values() if exp > now) + + # -- AuthClient protocol -------------------------------------------------- + + def authenticate(self, token: str) -> bool: + """Authenticate using a bearer token. + + Verifies that *token* is registered and has not expired. + + Args: + token: The plaintext bearer token. + + Returns: + ``True`` when authentication succeeds. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be a non-empty string") + hashed = _hash_token(token) + expiry = self._tokens.get(hashed) + if expiry is None: + _logger.warning("auth.failed.unknown_token", token_hash=hashed[:12]) + return False + if time.time() > expiry: + _logger.warning("auth.failed.expired", token_hash=hashed[:12]) + return False + _logger.debug("auth.success", token_hash=hashed[:12]) + return True + + def validate_token(self, token: str) -> bool: + """Validate a token without performing a full auth flow. + + Equivalent to :meth:`authenticate` for bearer tokens: checks + registration and expiry. + + Args: + token: The plaintext bearer token. + + Returns: + ``True`` when the token is valid and not expired. + """ + if not token or not isinstance(token, str): + raise ValueError("token must be a non-empty string") + return self.authenticate(token) + + __all__ = [ "AuthClient", "RemoteExecutionClient", @@ -194,4 +329,5 @@ __all__ = [ "StubAuthClient", "StubRemoteExecutionClient", "StubServerClient", + "TokenAuthClient", ] diff --git a/src/cleveragents/a2a/facade.py b/src/cleveragents/a2a/facade.py index 8fdebfca4..5eb52b8c6 100644 --- a/src/cleveragents/a2a/facade.py +++ b/src/cleveragents/a2a/facade.py @@ -39,6 +39,10 @@ from cleveragents.a2a.models import ( if TYPE_CHECKING: from cleveragents.a2a.events import A2aEventQueue + from cleveragents.a2a.server.authorization_service import AuthorizationService + from cleveragents.a2a.server.diagnostics_service import DiagnosticsService + from cleveragents.a2a.server.health_service import HealthService + from cleveragents.a2a.server.namespace_service import NamespaceService from cleveragents.application.services.plan_lifecycle_service import ( PlanLifecycleService, ) @@ -155,6 +159,22 @@ class A2aLocalFacade: def _event_queue(self) -> A2aEventQueue | None: return self._services.get("event_queue") # type: ignore[return-value] + @property + def _namespace_service(self) -> NamespaceService | None: + return self._services.get("namespace_service") # type: ignore[return-value] + + @property + def _health_service(self) -> HealthService | None: + return self._services.get("health_service") # type: ignore[return-value] + + @property + def _diagnostics_service(self) -> DiagnosticsService | None: + return self._services.get("diagnostics_service") # type: ignore[return-value] + + @property + def _authorization_service(self) -> AuthorizationService | None: + return self._services.get("authorization_service") # type: ignore[return-value] + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -285,10 +305,10 @@ class A2aLocalFacade: "_cleveragents/sync/pull": self._handle_sync_stub, "_cleveragents/sync/push": self._handle_sync_stub, "_cleveragents/sync/status": self._handle_sync_stub, - # Namespace (stub) - "_cleveragents/namespace/list": self._handle_namespace_stub, - "_cleveragents/namespace/show": self._handle_namespace_stub, - "_cleveragents/namespace/members": self._handle_namespace_stub, + # Namespace + "_cleveragents/namespace/list": self._handle_namespace_list, + "_cleveragents/namespace/show": self._handle_namespace_show, + "_cleveragents/namespace/members": self._handle_namespace_members, # --- Legacy proprietary names (deprecated) --- "session.create": self._handle_session_create, "session.close": self._handle_session_close, @@ -584,10 +604,16 @@ class A2aLocalFacade: # ------------------------------------------------------------------ def _handle_health_check(self, params: dict[str, Any]) -> dict[str, Any]: - return {"status": "healthy", "services": {}} + svc = self._health_service + if svc is None: + return {"status": "healthy", "services": {}} + return svc.check() def _handle_diagnostics_run(self, params: dict[str, Any]) -> dict[str, Any]: - return {"status": "ok", "diagnostics": {}, "stub": True} + svc = self._diagnostics_service + if svc is None: + return {"status": "ok", "diagnostics": {}, "stub": True} + return svc.run() # ------------------------------------------------------------------ # Extension handlers — _cleveragents/sync/* (stubs) @@ -597,11 +623,28 @@ class A2aLocalFacade: return {"status": "not_implemented", "stub": True} # ------------------------------------------------------------------ - # Extension handlers — _cleveragents/namespace/* (stubs) + # Extension handlers — _cleveragents/namespace/* # ------------------------------------------------------------------ - def _handle_namespace_stub(self, params: dict[str, Any]) -> dict[str, Any]: - return {"status": "not_implemented", "stub": True} + def _handle_namespace_list(self, params: dict[str, Any]) -> dict[str, Any]: + svc = self._namespace_service + if svc is None: + return {"namespaces": [], "stub": True} + return {"namespaces": svc.list_namespaces()} + + def _handle_namespace_show(self, params: dict[str, Any]) -> dict[str, Any]: + svc = self._namespace_service + name = params.get("name", "default") + if svc is None: + return {"name": name, "stub": True} + return svc.show(name) + + def _handle_namespace_members(self, params: dict[str, Any]) -> dict[str, Any]: + svc = self._namespace_service + name = params.get("name", "default") + if svc is None: + return {"name": name, "members": [], "stub": True} + return {"name": name, "members": svc.members(name)} __all__ = [ diff --git a/src/cleveragents/a2a/server/__init__.py b/src/cleveragents/a2a/server/__init__.py new file mode 100644 index 000000000..e10e98b6b --- /dev/null +++ b/src/cleveragents/a2a/server/__init__.py @@ -0,0 +1,27 @@ +"""Server-mode authentication, authorization, and namespace services. + +Provides concrete implementations for server deployments: + +- :class:`TokenAuthClient` — bearer-token authentication (re-exported + from :mod:`cleveragents.a2a.clients`) +- :class:`AuthorizationService` — namespace-scoped access enforcement +- :class:`NamespaceService` — list / show / members namespace queries +- :class:`HealthService` — health-check aggregation +- :class:`DiagnosticsService` — runtime diagnostics collection +""" + +from __future__ import annotations + +from cleveragents.a2a.clients import TokenAuthClient +from cleveragents.a2a.server.authorization_service import AuthorizationService +from cleveragents.a2a.server.diagnostics_service import DiagnosticsService +from cleveragents.a2a.server.health_service import HealthService +from cleveragents.a2a.server.namespace_service import NamespaceService + +__all__ = [ + "AuthorizationService", + "DiagnosticsService", + "HealthService", + "NamespaceService", + "TokenAuthClient", +] diff --git a/src/cleveragents/a2a/server/authorization_service.py b/src/cleveragents/a2a/server/authorization_service.py new file mode 100644 index 000000000..514722411 --- /dev/null +++ b/src/cleveragents/a2a/server/authorization_service.py @@ -0,0 +1,202 @@ +"""Namespace-scoped authorization enforcement for server mode. + +Evaluates whether a given user has permission to perform an operation +within a specific namespace. Access control lists (ACLs) are stored +in-memory and can be seeded from a persistent store at startup. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import structlog + +from cleveragents.core.exceptions import AuthorizationError + +_logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + +# Role hierarchy — higher index means more privilege. +_ROLE_HIERARCHY: dict[str, int] = { + "viewer": 0, + "member": 1, + "admin": 2, + "owner": 3, +} + +# Minimum role required per operation category. +_OPERATION_REQUIREMENTS: dict[str, str] = { + "read": "viewer", + "write": "member", + "admin": "admin", + "owner": "owner", +} + + +@dataclass(frozen=True) +class NamespaceACL: + """Access-control entry linking a user to a role within a namespace.""" + + user_id: str + namespace: str + role: str # one of _ROLE_HIERARCHY keys + + +class AuthorizationService: + """Namespace-scoped access enforcement for server mode. + + Maintains an in-memory ACL table and exposes :meth:`check_access` + which raises :class:`AuthorizationError` when a user lacks the + required role for an operation in a namespace. + + Typical usage:: + + authz = AuthorizationService() + authz.grant("u-1", "default", "admin") + authz.check_access("u-1", "default", "write") # passes + authz.check_access("u-1", "default", "owner") # raises + """ + + def __init__(self) -> None: + # Mapping: (user_id, namespace) -> role string + self._acls: dict[tuple[str, str], str] = {} + + # -- Management API ------------------------------------------------------- + + def grant(self, user_id: str, namespace: str, role: str) -> None: + """Grant a role to a user within a namespace. + + Args: + user_id: The user identifier. + namespace: The target namespace name. + role: One of ``viewer``, ``member``, ``admin``, ``owner``. + + Raises: + ValueError: If *role* is not a recognised role. + """ + if role not in _ROLE_HIERARCHY: + raise ValueError( + f"Unknown role {role!r}; expected one of " + f"{sorted(_ROLE_HIERARCHY.keys())}" + ) + self._acls[(user_id, namespace)] = role + _logger.debug( + "authz.grant", + user_id=user_id, + namespace=namespace, + role=role, + ) + + def revoke(self, user_id: str, namespace: str) -> bool: + """Revoke a user's access to a namespace. + + Args: + user_id: The user identifier. + namespace: The target namespace name. + + Returns: + ``True`` if an ACL entry was found and removed. + """ + removed = self._acls.pop((user_id, namespace), None) is not None + if removed: + _logger.debug("authz.revoke", user_id=user_id, namespace=namespace) + return removed + + def list_grants(self, user_id: str) -> list[dict[str, str]]: + """List all namespace grants for a user. + + Args: + user_id: The user identifier. + + Returns: + A list of dicts with ``namespace`` and ``role`` keys. + """ + return [ + {"namespace": ns, "role": role} + for (uid, ns), role in self._acls.items() + if uid == user_id + ] + + # -- Enforcement API ------------------------------------------------------ + + def check_access( + self, + user_id: str, + namespace: str, + operation: str, + ) -> bool: + """Verify that *user_id* may perform *operation* in *namespace*. + + Args: + user_id: The user identifier. + namespace: The target namespace name. + operation: One of ``read``, ``write``, ``admin``, ``owner``. + + Returns: + ``True`` when the user has sufficient privilege. + + Raises: + AuthorizationError: If the user lacks the required role. + ValueError: If *operation* is not recognised. + """ + required_role = _OPERATION_REQUIREMENTS.get(operation) + if required_role is None: + raise ValueError( + f"Unknown operation {operation!r}; expected one of " + f"{sorted(_OPERATION_REQUIREMENTS.keys())}" + ) + + user_role = self._acls.get((user_id, namespace)) + if user_role is None: + raise AuthorizationError( + f"User {user_id!r} has no access to namespace {namespace!r}" + ) + + required_level = _ROLE_HIERARCHY[required_role] + user_level = _ROLE_HIERARCHY.get(user_role, -1) + + if user_level < required_level: + raise AuthorizationError( + f"User {user_id!r} has role {user_role!r} in namespace " + f"{namespace!r} but operation {operation!r} requires " + f"{required_role!r}" + ) + + _logger.debug( + "authz.check_passed", + user_id=user_id, + namespace=namespace, + operation=operation, + role=user_role, + ) + return True + + def get_user_role(self, user_id: str, namespace: str) -> str | None: + """Return the role of *user_id* in *namespace*, or ``None``. + + Args: + user_id: The user identifier. + namespace: The target namespace name. + + Returns: + The role string or ``None`` if no grant exists. + """ + return self._acls.get((user_id, namespace)) + + def to_acl_list(self) -> list[dict[str, Any]]: + """Serialise the entire ACL table as a list of dicts. + + Returns: + A list of dicts with ``user_id``, ``namespace``, and ``role`` + keys. + """ + return [ + {"user_id": uid, "namespace": ns, "role": role} + for (uid, ns), role in self._acls.items() + ] + + +__all__ = [ + "AuthorizationService", + "NamespaceACL", +] diff --git a/src/cleveragents/a2a/server/diagnostics_service.py b/src/cleveragents/a2a/server/diagnostics_service.py new file mode 100644 index 000000000..a51f1518a --- /dev/null +++ b/src/cleveragents/a2a/server/diagnostics_service.py @@ -0,0 +1,89 @@ +"""Runtime diagnostics collection service for server-mode deployments. + +Gathers system and application metrics for the +``_cleveragents/diagnostics/run`` A2A extension method. +""" + +from __future__ import annotations + +import platform +import sys +import time +from collections.abc import Callable +from typing import Any + +import structlog + +_logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + + +class DiagnosticsService: + """Runtime diagnostics collector. + + Captures Python version, platform info, loaded module counts, + uptime, and any custom diagnostic entries registered by services. + + Typical usage:: + + diag = DiagnosticsService() + diag.register_check("db", lambda: {"pool_size": 5}) + result = diag.run() + """ + + def __init__(self) -> None: + self._start_time: float = time.monotonic() + self._checks: dict[str, Callable[[], dict[str, Any]]] = {} + + def register_check( + self, + name: str, + check_fn: Callable[[], dict[str, Any]], + ) -> None: + """Register a custom diagnostic check. + + Args: + name: A descriptive name for the check. + check_fn: A callable returning a dict of diagnostic data. + """ + if not name: + raise ValueError("check name must not be empty") + self._checks[name] = check_fn + _logger.debug("diagnostics.check_registered", check=name) + + def run(self) -> dict[str, Any]: + """Execute all diagnostics and return a composite result. + + Returns: + A dict with ``status``, ``python``, ``platform``, + ``uptime_seconds``, ``loaded_modules``, and per-check + entries under ``checks``. + """ + uptime = round(time.monotonic() - self._start_time, 2) + + checks: dict[str, Any] = {} + for name, check_fn in self._checks.items(): + try: + checks[name] = check_fn() + except Exception as exc: + checks[name] = {"error": str(exc)} + _logger.warning("diagnostics.check_failed", check=name, error=str(exc)) + + return { + "status": "ok", + "python": { + "version": platform.python_version(), + "implementation": platform.python_implementation(), + }, + "platform": { + "system": platform.system(), + "machine": platform.machine(), + }, + "uptime_seconds": uptime, + "loaded_modules": len(sys.modules), + "checks": checks, + } + + +__all__ = [ + "DiagnosticsService", +] diff --git a/src/cleveragents/a2a/server/health_service.py b/src/cleveragents/a2a/server/health_service.py new file mode 100644 index 000000000..681dd6d91 --- /dev/null +++ b/src/cleveragents/a2a/server/health_service.py @@ -0,0 +1,101 @@ +"""Health-check aggregation service for server-mode deployments. + +Collects status from registered sub-services and produces a composite +health response suitable for Kubernetes liveness/readiness probes and +the ``_cleveragents/health/check`` A2A extension method. +""" + +from __future__ import annotations + +import time +from typing import Any, Protocol, runtime_checkable + +import structlog + +_logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + + +@runtime_checkable +class HealthProbe(Protocol): + """Protocol for components that can report their health status.""" + + def health_status(self) -> dict[str, Any]: + """Return a health-status dict. + + Must include at least a ``"status"`` key with value + ``"healthy"`` or ``"unhealthy"``. + """ + ... + + +class HealthService: + """Aggregate health-check service. + + Iterates over registered :class:`HealthProbe` instances, collects + their individual statuses, and produces a composite result. + + The overall status is ``"healthy"`` only when every registered + probe reports ``"healthy"``. + + Typical usage:: + + health = HealthService() + health.register("database", db_probe) + result = health.check() + # {"status": "healthy", "services": {"database": {"status": "healthy"}}, ...} + """ + + def __init__(self) -> None: + self._probes: dict[str, HealthProbe] = {} + self._start_time: float = time.monotonic() + + def register(self, name: str, probe: HealthProbe) -> None: + """Register a named health probe. + + Args: + name: Human-readable service name. + probe: An object implementing :class:`HealthProbe`. + """ + if not name: + raise ValueError("probe name must not be empty") + self._probes[name] = probe + _logger.debug("health.probe_registered", probe=name) + + def check(self) -> dict[str, Any]: + """Run all registered probes and return a composite health result. + + Returns: + A dict with ``status`` (``"healthy"`` / ``"unhealthy"``), + ``services`` (per-probe results), and ``uptime_seconds``. + """ + services: dict[str, Any] = {} + all_healthy = True + + for name, probe in self._probes.items(): + try: + result = probe.health_status() + services[name] = result + if result.get("status") != "healthy": + all_healthy = False + except Exception as exc: + services[name] = {"status": "unhealthy", "error": str(exc)} + all_healthy = False + _logger.warning("health.probe_failed", probe=name, error=str(exc)) + + uptime = round(time.monotonic() - self._start_time, 2) + return { + "status": "healthy" if all_healthy else "unhealthy", + "services": services, + "uptime_seconds": uptime, + } + + @property + def probe_count(self) -> int: + """Return the number of registered probes.""" + return len(self._probes) + + +__all__ = [ + "HealthProbe", + "HealthService", +] diff --git a/src/cleveragents/a2a/server/namespace_service.py b/src/cleveragents/a2a/server/namespace_service.py new file mode 100644 index 000000000..5efde0781 --- /dev/null +++ b/src/cleveragents/a2a/server/namespace_service.py @@ -0,0 +1,178 @@ +"""Namespace query service for server-mode multi-tenant deployments. + +Provides list, show, and members endpoints that operate over an +in-memory namespace store. The store can be seeded from a database +at startup; the service itself is storage-agnostic. +""" + +from __future__ import annotations + +from typing import Any + +import structlog +from pydantic import BaseModel, ConfigDict, Field + +from cleveragents.core.exceptions import ResourceNotFoundError + +_logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__) + + +class NamespaceMember(BaseModel): + """A member within a namespace.""" + + model_config = ConfigDict(frozen=True) + + user_id: str + username: str + role: str # "owner" | "admin" | "member" | "viewer" + + +class NamespaceRecord(BaseModel): + """In-memory representation of a namespace.""" + + namespace_id: str + name: str + description: str = "" + owner_id: str = "" + members: list[NamespaceMember] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class NamespaceService: + """Namespace query service for server mode. + + Maintains an in-memory registry of namespaces and provides + list/show/members query endpoints that map to the + ``_cleveragents/namespace/*`` A2A extension methods. + + Typical usage:: + + svc = NamespaceService() + svc.register_namespace(NamespaceRecord( + namespace_id="ns-1", name="default", owner_id="u-1", + )) + ns = svc.show("default") + """ + + def __init__(self) -> None: + # Mapping: name -> NamespaceRecord + self._namespaces: dict[str, NamespaceRecord] = {} + + # -- Management API ------------------------------------------------------- + + def register_namespace(self, record: NamespaceRecord) -> None: + """Register or update a namespace in the in-memory store. + + Args: + record: The :class:`NamespaceRecord` to register. + + Raises: + ValueError: If *record* has an empty name. + """ + if not record.name: + raise ValueError("namespace name must not be empty") + self._namespaces[record.name] = record + _logger.debug( + "namespace.registered", + namespace=record.name, + namespace_id=record.namespace_id, + ) + + def remove_namespace(self, name: str) -> bool: + """Remove a namespace by name. + + Args: + name: The namespace name to remove. + + Returns: + ``True`` if the namespace was found and removed. + """ + removed = self._namespaces.pop(name, None) is not None + if removed: + _logger.debug("namespace.removed", namespace=name) + return removed + + # -- Query API (A2A endpoint backing) ------------------------------------- + + def list_namespaces(self) -> list[dict[str, Any]]: + """Return a summary list of all registered namespaces. + + Returns: + A list of dicts with ``namespace_id``, ``name``, and + ``description`` keys. + """ + return [ + { + "namespace_id": ns.namespace_id, + "name": ns.name, + "description": ns.description, + } + for ns in self._namespaces.values() + ] + + def show(self, name: str) -> dict[str, Any]: + """Return detailed information for a single namespace. + + Args: + name: The namespace name. + + Returns: + A dict with namespace details including owner and member count. + + Raises: + ResourceNotFoundError: If no namespace with *name* exists. + """ + record = self._namespaces.get(name) + if record is None: + raise ResourceNotFoundError( + resource_type="namespace", + resource_id=name, + ) + return { + "namespace_id": record.namespace_id, + "name": record.name, + "description": record.description, + "owner_id": record.owner_id, + "member_count": len(record.members), + "metadata": dict(record.metadata), + } + + def members(self, name: str) -> list[dict[str, str]]: + """Return the members of a namespace. + + Args: + name: The namespace name. + + Returns: + A list of dicts with ``user_id``, ``username``, and ``role`` + keys. + + Raises: + ResourceNotFoundError: If no namespace with *name* exists. + """ + record = self._namespaces.get(name) + if record is None: + raise ResourceNotFoundError( + resource_type="namespace", + resource_id=name, + ) + return [ + { + "user_id": m.user_id, + "username": m.username, + "role": m.role, + } + for m in record.members + ] + + @property + def namespace_count(self) -> int: + """Return the number of registered namespaces.""" + return len(self._namespaces) + + +__all__ = [ + "NamespaceMember", + "NamespaceRecord", + "NamespaceService", +] diff --git a/src/cleveragents/infrastructure/database/models.py b/src/cleveragents/infrastructure/database/models.py index d9b4e8ca6..89292f8e0 100644 --- a/src/cleveragents/infrastructure/database/models.py +++ b/src/cleveragents/infrastructure/database/models.py @@ -3576,3 +3576,81 @@ class IndexedFileModel(Base): # it for lookups already; no separate index needed. Index("ix_indexed_files_language", "language"), ) + + +# --------------------------------------------------------------------------- +# Server-mode authentication, authorization, and namespace tables +# --------------------------------------------------------------------------- + + +class ServerUserModel(Base): + """Registered user for server-mode multi-tenant deployments. + + Maps to the ``server_users`` table. + """ + + __tablename__ = "server_users" + + user_id: Mapped[str] = mapped_column(String(26), primary_key=True) + username: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + display_name: Mapped[str] = mapped_column(String(256), nullable=False, default="") + email: Mapped[str] = mapped_column(String(320), nullable=False, default="") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[str] = mapped_column(String(40), nullable=False) + updated_at: Mapped[str] = mapped_column(String(40), nullable=False) + + __table_args__ = ( + Index("ix_server_users_username", "username"), + Index("ix_server_users_email", "email"), + ) + + +class ServerTokenModel(Base): + """Bearer token for server-mode authentication. + + Tokens are stored as SHA-256 hashes; the plaintext is never persisted. + Maps to the ``server_tokens`` table. + """ + + __tablename__ = "server_tokens" + + token_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[str] = mapped_column( + String(26), + ForeignKey("server_users.user_id", ondelete="CASCADE"), + nullable=False, + ) + label: Mapped[str] = mapped_column(String(128), nullable=False, default="") + expires_at: Mapped[str] = mapped_column(String(40), nullable=False) + created_at: Mapped[str] = mapped_column(String(40), nullable=False) + + __table_args__ = ( + Index("ix_server_tokens_user_id", "user_id"), + Index("ix_server_tokens_expires_at", "expires_at"), + ) + + +class NamespaceACLModel(Base): + """Access-control entry linking a user to a role within a namespace. + + Maps to the ``namespace_acls`` table. + """ + + __tablename__ = "namespace_acls" + + user_id: Mapped[str] = mapped_column( + String(26), + ForeignKey("server_users.user_id", ondelete="CASCADE"), + primary_key=True, + ) + namespace: Mapped[str] = mapped_column(String(128), primary_key=True) + role: Mapped[str] = mapped_column(String(20), nullable=False) + granted_at: Mapped[str] = mapped_column(String(40), nullable=False) + + __table_args__ = ( + CheckConstraint( + "role IN ('viewer', 'member', 'admin', 'owner')", + name="ck_namespace_acls_role", + ), + Index("ix_namespace_acls_namespace", "namespace"), + ) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index cef933f03..d99c4db64 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -1203,3 +1203,30 @@ create_workspace_snapshot # noqa: B018, F821 selective_rollback # noqa: B018, F821 archive_artifacts # noqa: B018, F821 revert_decisions # noqa: B018, F821 +# Server-mode auth, authorization, and namespace services (#927) +TokenAuthClient # noqa: B018, F821 +register_token # noqa: B018, F821 +revoke_token # noqa: B018, F821 +active_token_count # noqa: B018, F821 +AuthorizationService # noqa: B018, F821 +NamespaceACL # noqa: B018, F821 +check_access # noqa: B018, F821 +get_user_role # noqa: B018, F821 +to_acl_list # noqa: B018, F821 +list_grants # noqa: B018, F821 +NamespaceService # noqa: B018, F821 +NamespaceRecord # noqa: B018, F821 +NamespaceMember # noqa: B018, F821 +register_namespace # noqa: B018, F821 +remove_namespace # noqa: B018, F821 +list_namespaces # noqa: B018, F821 +namespace_count # noqa: B018, F821 +HealthService # noqa: B018, F821 +HealthProbe # noqa: B018, F821 +health_status # noqa: B018, F821 +probe_count # noqa: B018, F821 +DiagnosticsService # noqa: B018, F821 +register_check # noqa: B018, F821 +ServerUserModel # noqa: B018, F821 +ServerTokenModel # noqa: B018, F821 +NamespaceACLModel # noqa: B018, F821 -- 2.52.0