mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-05-19 20:35:13 +02:00
feat: add no-auth IAM regime as a drop-in replacement for iam-svc (#933)
Adds `no-auth-svc`, a lightweight IAM service that permits all access unconditionally — no database, no bootstrap, no signing keys. Deploy it in place of `iam-svc` for development, demos, and single-user setups where authentication overhead is unwanted. The gateway no longer hard-codes a 401 on missing credentials. Instead it asks the IAM regime via a new `authenticate-anonymous` operation whether token-free access is allowed. This keeps the gateway regime-agnostic: `iam-svc` rejects anonymous auth (preserving existing security), while `no-auth-svc` grants it with a configurable default user and workspace. Includes a tech spec (docs/tech-specs/no-auth-regime.md) and tests that pin the safety boundary — malformed tokens never fall through to the anonymous path, and a contract test ensures the full iam-svc always rejects `authenticate-anonymous`.
This commit is contained in:
parent
ab83c81d8a
commit
da7d10e995
16 changed files with 876 additions and 32 deletions
|
|
@ -165,22 +165,37 @@ class TestIamAuthDispatch:
|
|||
by shape of the bearer."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_authorization_header_raises_401(self):
|
||||
async def test_no_authorization_header_tries_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request(None))
|
||||
|
||||
async def fake_with_client(op):
|
||||
raise RuntimeError("auth-failed: anonymous access not permitted")
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request(None))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_bearer_header_raises_401(self):
|
||||
async def test_non_bearer_header_tries_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Basic whatever"))
|
||||
|
||||
async def fake_with_client(op):
|
||||
raise RuntimeError("auth-failed: anonymous access not permitted")
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Basic whatever"))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_bearer_raises_401(self):
|
||||
async def test_empty_bearer_tries_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Bearer "))
|
||||
|
||||
async def fake_with_client(op):
|
||||
raise RuntimeError("auth-failed: anonymous access not permitted")
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Bearer "))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_format_raises_401(self):
|
||||
|
|
@ -445,3 +460,121 @@ class TestAuthorise:
|
|||
|
||||
# Different resource → different cache key → two IAM calls.
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
# -- Anonymous authentication boundary ------------------------------------
|
||||
|
||||
|
||||
class TestAnonymousAuthBoundary:
|
||||
"""The gateway must only attempt anonymous auth when no credential
|
||||
is presented. A malformed token must NOT fall through to the
|
||||
anonymous path — that would let an attacker bypass a broken token
|
||||
by simply sending garbage."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_header_attempts_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
|
||||
async def fake_with_client(op):
|
||||
return await op(Mock(
|
||||
authenticate_anonymous=AsyncMock(
|
||||
return_value=("anon", "default", ["reader"]),
|
||||
)
|
||||
))
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
ident = await auth.authenticate(make_request(None))
|
||||
assert ident.handle == "anon"
|
||||
assert ident.source == "anonymous"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_bearer_attempts_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
|
||||
async def fake_with_client(op):
|
||||
return await op(Mock(
|
||||
authenticate_anonymous=AsyncMock(
|
||||
return_value=("anon", "default", ["reader"]),
|
||||
)
|
||||
))
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
ident = await auth.authenticate(make_request("Bearer "))
|
||||
assert ident.handle == "anon"
|
||||
assert ident.source == "anonymous"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_token_does_not_fall_through_to_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
called = {"anonymous": False}
|
||||
|
||||
original = auth._authenticate_anonymous
|
||||
|
||||
async def spy_anonymous():
|
||||
called["anonymous"] = True
|
||||
return await original()
|
||||
|
||||
auth._authenticate_anonymous = spy_anonymous
|
||||
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Bearer garbage"))
|
||||
assert not called["anonymous"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_api_key_does_not_fall_through_to_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
called = {"anonymous": False}
|
||||
|
||||
async def spy_anonymous():
|
||||
called["anonymous"] = True
|
||||
|
||||
auth._authenticate_anonymous = spy_anonymous
|
||||
|
||||
async def fake_with_client(op):
|
||||
raise RuntimeError("auth-failed: unknown key")
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Bearer tg_bad"))
|
||||
assert not called["anonymous"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_jwt_does_not_fall_through_to_anonymous(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
auth._signing_public_pem = "not-a-real-pem"
|
||||
called = {"anonymous": False}
|
||||
|
||||
async def spy_anonymous():
|
||||
called["anonymous"] = True
|
||||
|
||||
auth._authenticate_anonymous = spy_anonymous
|
||||
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request("Bearer a.b.c"))
|
||||
assert not called["anonymous"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_rejected_by_iam_raises_401(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
|
||||
async def fake_with_client(op):
|
||||
raise RuntimeError("auth-failed: anonymous access not permitted")
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request(None))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_with_empty_user_id_raises_401(self):
|
||||
auth = IamAuth(backend=Mock())
|
||||
|
||||
async def fake_with_client(op):
|
||||
return await op(Mock(
|
||||
authenticate_anonymous=AsyncMock(
|
||||
return_value=("", "default", []),
|
||||
)
|
||||
))
|
||||
|
||||
with patch.object(auth, "_with_client", side_effect=fake_with_client):
|
||||
with pytest.raises(web.HTTPUnauthorized):
|
||||
await auth.authenticate(make_request(None))
|
||||
|
|
|
|||
0
tests/unit/test_iam/__init__.py
Normal file
0
tests/unit/test_iam/__init__.py
Normal file
44
tests/unit/test_iam/test_iam_rejects_anonymous.py
Normal file
44
tests/unit/test_iam/test_iam_rejects_anonymous.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""
|
||||
Contract test: the full iam-svc MUST reject authenticate-anonymous.
|
||||
|
||||
This is a safety pin — if someone accidentally adds anonymous access
|
||||
to the production IAM handler, this test catches it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import Mock, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from trustgraph.iam.service.iam import IamService
|
||||
|
||||
|
||||
def _make_request(**kwargs):
|
||||
req = Mock()
|
||||
for k, v in kwargs.items():
|
||||
setattr(req, k, v)
|
||||
return req
|
||||
|
||||
|
||||
class TestIamRejectsAnonymous:
|
||||
|
||||
@pytest.fixture
|
||||
def handler(self):
|
||||
svc = object.__new__(IamService)
|
||||
svc.table_store = Mock(spec=[])
|
||||
svc.bootstrap_mode = "token"
|
||||
svc.bootstrap_token = "tok"
|
||||
svc._on_workspace_created = None
|
||||
svc._on_workspace_deleted = None
|
||||
svc._signing_key = None
|
||||
svc._signing_key_lock = asyncio.Lock()
|
||||
return svc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_anonymous_returns_auth_failed(self, handler):
|
||||
resp = await handler.handle(
|
||||
_make_request(operation="authenticate-anonymous")
|
||||
)
|
||||
assert resp.error is not None
|
||||
assert resp.error.type == "auth-failed"
|
||||
assert "anonymous" in resp.error.message.lower()
|
||||
138
tests/unit/test_iam/test_noauth_handler.py
Normal file
138
tests/unit/test_iam/test_noauth_handler.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
Tests for the no-auth IAM handler.
|
||||
|
||||
Verifies that NoAuthHandler returns the expected permissive responses
|
||||
and that the always-allow authorise path returns the correct shape.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from trustgraph.iam.noauth.handler import NoAuthHandler
|
||||
|
||||
|
||||
def _make_request(**kwargs):
|
||||
req = Mock()
|
||||
for k, v in kwargs.items():
|
||||
setattr(req, k, v)
|
||||
return req
|
||||
|
||||
|
||||
class TestAuthenticateAnonymous:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_default_identity(self):
|
||||
h = NoAuthHandler(
|
||||
default_user_id="anon", default_workspace="ws",
|
||||
)
|
||||
resp = await h.handle(
|
||||
_make_request(operation="authenticate-anonymous")
|
||||
)
|
||||
assert resp.error is None
|
||||
assert resp.resolved_user_id == "anon"
|
||||
assert resp.resolved_workspace == "ws"
|
||||
assert "admin" in list(resp.resolved_roles)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_defaults_propagate(self):
|
||||
h = NoAuthHandler(
|
||||
default_user_id="dev-user", default_workspace="dev-ws",
|
||||
)
|
||||
resp = await h.handle(
|
||||
_make_request(operation="authenticate-anonymous")
|
||||
)
|
||||
assert resp.resolved_user_id == "dev-user"
|
||||
assert resp.resolved_workspace == "dev-ws"
|
||||
|
||||
|
||||
class TestResolveApiKey:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_any_key_resolves_to_default_identity(self):
|
||||
h = NoAuthHandler()
|
||||
resp = await h.handle(
|
||||
_make_request(operation="resolve-api-key", api_key="tg_bogus")
|
||||
)
|
||||
assert resp.error is None
|
||||
assert resp.resolved_user_id == "anonymous"
|
||||
assert resp.resolved_workspace == "default"
|
||||
|
||||
|
||||
class TestAuthorise:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_always_allows(self):
|
||||
h = NoAuthHandler()
|
||||
resp = await h.handle(
|
||||
_make_request(
|
||||
operation="authorise",
|
||||
user_id="anyone",
|
||||
capability="anything",
|
||||
resource_json="{}",
|
||||
parameters_json="{}",
|
||||
)
|
||||
)
|
||||
assert resp.error is None
|
||||
assert resp.decision_allow is True
|
||||
assert resp.decision_ttl_seconds > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorise_many_returns_matching_count(self):
|
||||
h = NoAuthHandler()
|
||||
checks = [
|
||||
{"capability": "a", "resource": {}, "parameters": {}},
|
||||
{"capability": "b", "resource": {}, "parameters": {}},
|
||||
{"capability": "c", "resource": {}, "parameters": {}},
|
||||
]
|
||||
resp = await h.handle(
|
||||
_make_request(
|
||||
operation="authorise-many",
|
||||
user_id="u",
|
||||
authorise_checks=json.dumps(checks),
|
||||
)
|
||||
)
|
||||
assert resp.error is None
|
||||
decisions = json.loads(resp.decisions_json)
|
||||
assert len(decisions) == 3
|
||||
assert all(d["allow"] is True for d in decisions)
|
||||
|
||||
|
||||
class TestCreateWorkspaceCallback:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_calls_callback(self):
|
||||
called_with = []
|
||||
|
||||
async def on_created(ws_id):
|
||||
called_with.append(ws_id)
|
||||
|
||||
h = NoAuthHandler(on_workspace_created=on_created)
|
||||
req = _make_request(operation="create-workspace")
|
||||
req.workspace_record = Mock()
|
||||
req.workspace_record.id = "test-ws"
|
||||
resp = await h.handle(req)
|
||||
assert resp.error is None
|
||||
assert called_with == ["test-ws"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workspace_without_callback_still_succeeds(self):
|
||||
h = NoAuthHandler()
|
||||
req = _make_request(operation="create-workspace")
|
||||
req.workspace_record = Mock()
|
||||
req.workspace_record.id = "test-ws"
|
||||
resp = await h.handle(req)
|
||||
assert resp.error is None
|
||||
|
||||
|
||||
class TestUnknownOperation:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_op_returns_error(self):
|
||||
h = NoAuthHandler()
|
||||
resp = await h.handle(
|
||||
_make_request(operation="not-a-real-op")
|
||||
)
|
||||
assert resp.error is not None
|
||||
assert resp.error.type == "invalid-argument"
|
||||
Loading…
Add table
Add a link
Reference in a new issue