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
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
1
trustgraph-flow/trustgraph/iam/noauth/__init__.py
Normal file
1
trustgraph-flow/trustgraph/iam/noauth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . service import *
|
||||
4
trustgraph-flow/trustgraph/iam/noauth/__main__.py
Normal file
4
trustgraph-flow/trustgraph/iam/noauth/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
from . service import run
|
||||
|
||||
run()
|
||||
131
trustgraph-flow/trustgraph/iam/noauth/handler.py
Normal file
131
trustgraph-flow/trustgraph/iam/noauth/handler.py
Normal 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))
|
||||
182
trustgraph-flow/trustgraph/iam/noauth/service.py
Normal file
182
trustgraph-flow/trustgraph/iam/noauth/service.py
Normal 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__)
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue