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:
cybermaggedon 2026-05-18 14:10:05 +01:00 committed by GitHub
parent ab83c81d8a
commit da7d10e995
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 876 additions and 32 deletions

View file

@ -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))

View file

View 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()

View 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"