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

@ -64,6 +64,7 @@ bootstrap = "trustgraph.bootstrap.bootstrapper:run"
config-svc = "trustgraph.config.service:run"
flow-svc = "trustgraph.flow.service:run"
iam-svc = "trustgraph.iam.service:run"
no-auth-svc = "trustgraph.iam.noauth:run"
doc-embeddings-query-milvus = "trustgraph.query.doc_embeddings.milvus:run"
doc-embeddings-query-pinecone = "trustgraph.query.doc_embeddings.pinecone:run"
doc-embeddings-query-qdrant = "trustgraph.query.doc_embeddings.qdrant:run"

View file

@ -233,10 +233,10 @@ class IamAuth:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
raise _auth_failure()
return await self._authenticate_anonymous()
token = header[len("Bearer "):].strip()
if not token:
raise _auth_failure()
return await self._authenticate_anonymous()
# API keys always start with "tg_". JWTs have two dots and
# no "tg_" prefix. Discriminate cheaply.
@ -266,6 +266,26 @@ class IamAuth:
handle=sub, workspace=ws, principal_id=sub, source="jwt",
)
async def _authenticate_anonymous(self):
try:
async def _call(client):
return await client.authenticate_anonymous()
user_id, workspace, _roles = await self._with_client(_call)
except Exception as e:
logger.debug(
f"Anonymous authentication rejected: "
f"{type(e).__name__}: {e}"
)
raise _auth_failure()
if not user_id or not workspace:
raise _auth_failure()
return Identity(
handle=user_id, workspace=workspace,
principal_id=user_id, source="anonymous",
)
async def _resolve_api_key(self, plaintext):
h = hashlib.sha256(plaintext.encode("utf-8")).hexdigest()

View file

@ -57,16 +57,13 @@ class Mux:
(important for browsers, which treat a handshake-time 401
as terminal)."""
token = data.get("token", "")
if not token:
await self.ws.send_json({
"type": "auth-failed",
"error": "auth failure",
})
return
class _Shim:
def __init__(self, tok):
self.headers = {"Authorization": f"Bearer {tok}"}
self.headers = (
{"Authorization": f"Bearer {tok}"} if tok
else {}
)
try:
identity = await self.auth.authenticate(_Shim(token))

View file

@ -0,0 +1 @@
from . service import *

View file

@ -0,0 +1,4 @@
from . service import run
run()

View file

@ -0,0 +1,131 @@
"""
No-auth IAM handler. Implements the IAM contract with every operation
returning a permissive or stub response. No database, no crypto,
no state.
"""
import json
import logging
from trustgraph.schema import IamResponse, Error, UserRecord
logger = logging.getLogger(__name__)
def _err(type, message):
return IamResponse(error=Error(type=type, message=message))
class NoAuthHandler:
def __init__(self, default_user_id="anonymous",
default_workspace="default",
on_workspace_created=None):
self.default_user_id = default_user_id
self.default_workspace = default_workspace
self._on_workspace_created = on_workspace_created
def _default_identity_response(self):
return IamResponse(
resolved_user_id=self.default_user_id,
resolved_workspace=self.default_workspace,
resolved_roles=["admin"],
)
def _default_user_record(self):
return UserRecord(
id=self.default_user_id,
workspace=self.default_workspace,
username=self.default_user_id,
name="Anonymous User",
roles=["admin"],
enabled=True,
)
async def handle(self, v):
op = v.operation
try:
if op == "authenticate-anonymous":
return self._default_identity_response()
if op == "resolve-api-key":
return self._default_identity_response()
if op == "authorise":
return IamResponse(
decision_allow=True,
decision_ttl_seconds=3600,
)
if op == "authorise-many":
checks = json.loads(v.authorise_checks or "[]")
decisions = [
{"allow": True, "ttl": 3600}
for _ in checks
]
return IamResponse(
decisions_json=json.dumps(decisions),
)
if op == "get-signing-key-public":
return IamResponse(signing_key_public="")
if op == "bootstrap":
return IamResponse()
if op == "bootstrap-status":
return IamResponse(bootstrap_available=False)
if op == "whoami":
return IamResponse(user=self._default_user_record())
if op == "login":
return IamResponse()
if op in (
"create-user", "get-user", "update-user",
"disable-user", "enable-user",
):
return IamResponse(user=self._default_user_record())
if op == "list-users":
return IamResponse(users=[self._default_user_record()])
if op == "delete-user":
return IamResponse()
if op == "create-workspace":
if self._on_workspace_created and v.workspace_record:
await self._on_workspace_created(v.workspace_record.id)
return IamResponse()
if op in (
"get-workspace", "update-workspace",
"disable-workspace",
):
return IamResponse()
if op == "list-workspaces":
return IamResponse()
if op in ("create-api-key", "list-api-keys", "revoke-api-key"):
return IamResponse()
if op in ("change-password", "reset-password"):
return IamResponse()
if op == "rotate-signing-key":
return IamResponse()
return _err(
"invalid-argument",
f"unknown operation: {op!r}",
)
except Exception as e:
logger.error(
f"no-auth {op} failed: {type(e).__name__}: {e}",
exc_info=True,
)
return _err("internal-error", str(e))

View file

@ -0,0 +1,182 @@
"""
No-auth IAM service. Drop-in replacement for iam-svc that permits
all access unconditionally. No database, no bootstrap, no signing keys.
"""
import logging
import uuid
from trustgraph.schema import Error
from trustgraph.schema import IamRequest, IamResponse
from trustgraph.schema import iam_request_queue, iam_response_queue
from trustgraph.schema import ConfigRequest, ConfigResponse, ConfigValue
from trustgraph.schema import config_request_queue, config_response_queue
from trustgraph.base import AsyncProcessor, Consumer, Producer
from trustgraph.base import ConsumerMetrics, ProducerMetrics
from trustgraph.base.metrics import SubscriberMetrics
from trustgraph.base.request_response_spec import RequestResponse
from . handler import NoAuthHandler
logger = logging.getLogger(__name__)
default_ident = "no-auth-svc"
default_iam_request_queue = iam_request_queue
default_iam_response_queue = iam_response_queue
class Processor(AsyncProcessor):
def __init__(self, **params):
iam_req_q = params.get(
"iam_request_queue", default_iam_request_queue,
)
iam_resp_q = params.get(
"iam_response_queue", default_iam_response_queue,
)
default_user_id = params.get("default_user_id", "anonymous")
default_workspace = params.get("default_workspace", "default")
super().__init__(**params)
iam_request_metrics = ConsumerMetrics(
processor=self.id, flow=None, name="iam-request",
)
iam_response_metrics = ProducerMetrics(
processor=self.id, flow=None, name="iam-response",
)
self.iam_request_topic = iam_req_q
self.iam_request_consumer = Consumer(
taskgroup=self.taskgroup,
backend=self.pubsub,
flow=None,
topic=iam_req_q,
subscriber=self.id,
schema=IamRequest,
handler=self.on_iam_request,
metrics=iam_request_metrics,
)
self.iam_response_producer = Producer(
backend=self.pubsub,
topic=iam_resp_q,
schema=IamResponse,
metrics=iam_response_metrics,
)
self.handler = NoAuthHandler(
default_user_id=default_user_id,
default_workspace=default_workspace,
on_workspace_created=self._ensure_workspace_registered,
)
logger.info(
f"No-auth IAM service initialised "
f"(user={default_user_id}, workspace={default_workspace})"
)
async def start(self):
await self.pubsub.ensure_topic(self.iam_request_topic)
await self.iam_request_consumer.start()
def _create_config_client(self):
config_rr_id = str(uuid.uuid4())
config_req_metrics = ProducerMetrics(
processor=self.id, flow=None, name="config-request",
)
config_resp_metrics = SubscriberMetrics(
processor=self.id, flow=None, name="config-response",
)
return RequestResponse(
backend=self.pubsub,
subscription=f"{self.id}--config--{config_rr_id}",
consumer_name=self.id,
request_topic=config_request_queue,
request_schema=ConfigRequest,
request_metrics=config_req_metrics,
response_topic=config_response_queue,
response_schema=ConfigResponse,
response_metrics=config_resp_metrics,
)
async def _ensure_workspace_registered(self, workspace_id):
client = self._create_config_client()
try:
await client.start()
await client.request(
ConfigRequest(
operation="put",
workspace="__workspaces__",
values=[ConfigValue(
type="workspace", key=workspace_id,
value='{"enabled": true}',
)],
),
timeout=10,
)
finally:
await client.stop()
logger.info(
f"Registered workspace in config: {workspace_id}"
)
async def on_iam_request(self, msg, consumer, flow):
id = None
try:
v = msg.value()
id = msg.properties()["id"]
logger.debug(
f"Handling IAM request {id} op={v.operation!r}"
)
resp = await self.handler.handle(v)
await self.iam_response_producer.send(
resp, properties={"id": id},
)
except Exception as e:
logger.error(
f"IAM request failed: {type(e).__name__}: {e}",
exc_info=True,
)
resp = IamResponse(
error=Error(type="internal-error", message=str(e)),
)
if id is not None:
await self.iam_response_producer.send(
resp, properties={"id": id},
)
@staticmethod
def add_args(parser):
AsyncProcessor.add_args(parser)
parser.add_argument(
"--iam-request-queue",
default=default_iam_request_queue,
help=f"IAM request queue (default: {default_iam_request_queue})",
)
parser.add_argument(
"--iam-response-queue",
default=default_iam_response_queue,
help=f"IAM response queue (default: {default_iam_response_queue})",
)
parser.add_argument(
"--default-user-id",
default="anonymous",
help="User ID for all requests (default: anonymous)",
)
parser.add_argument(
"--default-workspace",
default="default",
help="Workspace for all requests (default: default)",
)
def run():
Processor.launch(default_ident, __doc__)

View file

@ -287,6 +287,9 @@ class IamService:
op = v.operation
try:
if op == "authenticate-anonymous":
return _err("auth-failed", "anonymous access not permitted")
if op == "bootstrap":
return await self.handle_bootstrap(v)
if op == "bootstrap-status":