Compare commits

...

1 Commits

Author SHA1 Message Date
freemo b0ab61134d feat(server): implement authentication, authorization, and namespace service
CI / lint (pull_request) Successful in 24s
CI / quality (pull_request) Failing after 4s
CI / unit_tests (pull_request) Failing after 1s
CI / integration_tests (pull_request) Failing after 1s
CI / e2e_tests (pull_request) Failing after 1s
CI / build (pull_request) Failing after 2s
CI / helm (pull_request) Failing after 1s
CI / typecheck (pull_request) Successful in 45s
CI / security (pull_request) Successful in 4m6s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Successful in 9m45s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 55m19s
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
2026-04-02 09:48:43 +00:00
16 changed files with 1915 additions and 12 deletions
@@ -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")
@@ -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
+159
View File
@@ -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
@@ -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())}"
)
+97
View File
@@ -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
+215
View File
@@ -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()
+2
View File
@@ -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",
]
+139 -3
View File
@@ -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",
]
+52 -9
View File
@@ -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__ = [
+27
View File
@@ -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",
]
@@ -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",
]
@@ -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",
]
@@ -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",
]
@@ -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",
]
@@ -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"),
)
+27
View File
@@ -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