Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ab61134d |
@@ -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
|
||||
@@ -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())}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user