Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ENV=prod
PUBLIC_BASE_URL=https://iplvr.it.ntnu.no
CORS_ALLOWED_ORIGINS=http://localhost:3001,http://localhost:4001

# TLS files on the Docker host. Override these if the server stores certs elsewhere.
SSL_CERT_PATH=/root/iplvr.it.ntnu.no.crt
Expand Down Expand Up @@ -29,6 +30,7 @@ KEYCLOAK_CLIENT_ID=ragdoll-config
KEYCLOAK_CLIENT_SECRET=replace-with-keycloak-client-secret
KEYCLOAK_VERIFY_AUDIENCE=false
KEYCLOAK_ISSUER=https://iplvr.it.ntnu.no/realms/ragdoll
KEYCLOAK_ALLOWED_ISSUERS=
KEYCLOAK_JWKS_URL=https://iplvr.it.ntnu.no/realms/ragdoll/protocol/openid-connect/certs
NEXTAUTH_SECRET=replace-with-a-long-random-secret
JWT_SECRET=replace-with-a-long-random-secret
Expand Down
11 changes: 6 additions & 5 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,12 @@ services:
- MONGODB_URI=mongodb://admin:changeme@mongodb:27017/ragdoll_dev?authSource=admin
- MONGODB_DATABASE=ragdoll_dev
- KEYCLOAK_BASE_URL=http://keycloak:8080
- KEYCLOAK_REALM=${KEYCLOAK_REALM:-ragdoll}
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-ragdoll-config}
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-ragdoll-config-secret}
- KEYCLOAK_VERIFY_AUDIENCE=${KEYCLOAK_VERIFY_AUDIENCE:-false}
- KEYCLOAK_ISSUER=${KEYCLOAK_ISSUER:-http://localhost:8080/realms/ragdoll}
- KEYCLOAK_REALM=ragdoll
- KEYCLOAK_CLIENT_ID=ragdoll-config
- KEYCLOAK_CLIENT_SECRET=ragdoll-config-secret
- KEYCLOAK_VERIFY_AUDIENCE=false
- KEYCLOAK_ISSUER=http://localhost:8080/realms/ragdoll
- KEYCLOAK_ALLOWED_ISSUERS=http://localhost:8080/realms/ragdoll,http://localhost/realms/ragdoll
- KEYCLOAK_JWKS_URL=http://keycloak:8080/realms/ragdoll/protocol/openid-connect/certs
- AUTH_BOOTSTRAP_ENABLED=${AUTH_BOOTSTRAP_ENABLED:-true}
networks:
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ services:
- ENV=prod
- RAGDOLL_CONFIG_API_URL=${PUBLIC_BASE_URL:-https://iplvr.it.ntnu.no}
- RAGDOLL_CHAT_API_URL=${PUBLIC_BASE_URL:-https://iplvr.it.ntnu.no}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost:3001,http://localhost:4001}
- MONGODB_URI=${MONGODB_URI:?Set MONGODB_URI in RAGdoll/.env. URL-encode special characters in the username/password.}
- MONGODB_DATABASE=${MONGODB_DATABASE:-ragdoll_prod}
- MONGODB_CONTEXT_COLLECTION=${MONGODB_CONTEXT_COLLECTION:-context}
Expand Down
7 changes: 6 additions & 1 deletion src/access_service/access_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ def get_unique_access_key_id(self, agent: Agent):
raise ValueError("Could not generate a new key!")

def generate_accesskey(
self, name: str, expiry_date: datetime | None, agent_id: str
self,
name: str,
expiry_date: datetime | None,
agent_id: str,
view_once: bool = True,
) -> AccessKey:
if (expiry_date is not None) and expiry_date < datetime.now():
raise ValueError("Expiry date cannot be in the past")
Expand All @@ -50,6 +54,7 @@ def generate_accesskey(
expiry_date=expiry_date,
created=datetime.now(),
last_use=None,
view_once=view_once,
)
agent.access_key.append(access_key)
self.agent_database.add_agent(agent)
Expand Down
2 changes: 1 addition & 1 deletion src/access_service/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
class AbstractAccessService:
@abstractmethod
def generate_accesskey(
self, name: str, expiry_date: datetime, agent_id: str
self, name: str, expiry_date: datetime | None, agent_id: str, view_once: bool = True
) -> AccessKey:
"""Generates an accesskey and adds it into the database, returns the generated key."""

Expand Down
1 change: 1 addition & 0 deletions src/auth/auth_provider/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def auth_provider_factory(provider: str, user_db: UserDao) -> AuthProvider:
case "keycloak":
return KeycloakAuthProvider(
issuer=config.KEYCLOAK_ISSUER,
allowed_issuers=config.KEYCLOAK_ALLOWED_ISSUERS,
jwks_url=config.KEYCLOAK_JWKS_URL,
client_id=config.KEYCLOAK_CLIENT_ID,
verify_audience=config.KEYCLOAK_VERIFY_AUDIENCE,
Expand Down
15 changes: 14 additions & 1 deletion src/auth/auth_provider/keycloak_auth_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import jwt
import requests
from jwt import InvalidIssuerError
from cryptography.hazmat.primitives.asymmetric import rsa

from src.auth.auth_provider.base import AuthProvider
Expand All @@ -28,12 +29,17 @@ class KeycloakAuthProvider(AuthProvider):
def __init__(
self,
issuer: str,
allowed_issuers: list[str] | None,
jwks_url: str,
client_id: str,
verify_audience: bool,
user_db: UserDao,
):
self.issuer = issuer.rstrip("/")
self.allowed_issuers = {
issuer.rstrip("/"),
*(item.rstrip("/") for item in (allowed_issuers or [])),
}
self.jwks_url = jwks_url
self.client_id = client_id
self.verify_audience = verify_audience
Expand Down Expand Up @@ -85,14 +91,21 @@ def _decode_token(self, token: str) -> dict[str, Any]:
decode_kwargs: dict[str, Any] = {
"key": public_key,
"algorithms": ["RS256"],
"issuer": self.issuer,
}
if self.verify_audience:
decode_kwargs["audience"] = self.client_id
else:
decode_kwargs["options"] = {"verify_aud": False}

claims = jwt.decode(token, **decode_kwargs)
token_issuer = str(claims.get("iss", "")).rstrip("/")
if token_issuer not in self.allowed_issuers:
logger.warning(
"Invalid Keycloak issuer. Expected one of %s, got '%s'",
sorted(self.allowed_issuers),
token_issuer,
)
raise InvalidIssuerError("Invalid issuer")
if claims.get("azp") and claims["azp"] != self.client_id:
logger.debug(
"Keycloak token authorized party '%s' does not match configured client '%s'",
Expand Down
10 changes: 10 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def __init__(self):
self.RAGDOLL_CHAT_API_URL = os.getenv(
"RAGDOLL_CHAT_API_URL", "http://localhost:3001"
)
self.CORS_ALLOWED_ORIGINS = [
origin.strip().rstrip("/")
for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
if origin.strip()
]

self.MODEL = os.getenv("MODEL", "idun")
self.GPT_MODEL = os.getenv("GPT_MODEL", "gpt-4o-mini")
Expand Down Expand Up @@ -96,6 +101,11 @@ def __init__(self):
"KEYCLOAK_ISSUER",
f"{self.KEYCLOAK_BASE_URL}/realms/{self.KEYCLOAK_REALM}",
).rstrip("/")
self.KEYCLOAK_ALLOWED_ISSUERS = [
issuer.strip().rstrip("/")
for issuer in os.getenv("KEYCLOAK_ALLOWED_ISSUERS", "").split(",")
if issuer.strip()
]
self.KEYCLOAK_JWKS_URL = os.getenv(
"KEYCLOAK_JWKS_URL",
f"{self.KEYCLOAK_ISSUER}/protocol/openid-connect/certs",
Expand Down
8 changes: 5 additions & 3 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
dictConfig(LOGGING_CONFIG)

logger = logging.getLogger(__name__)
config = Config()

app = FastAPI(
title="Chat-Service Microservice API",
Expand All @@ -37,9 +38,10 @@
app.add_middleware(
CORSMiddleware,
allow_origins=[
Config().RAGDOLL_CONFIG_API_URL,
Config().RAGDOLL_CHAT_API_URL,
], # TODO: Frontend URL(s), static rn for testing, but need env variable later
config.RAGDOLL_CONFIG_API_URL,
config.RAGDOLL_CHAT_API_URL,
*config.CORS_ALLOWED_ORIGINS,
],
allow_credentials=True,
allow_methods=["*"], # Allow GET, POST, PUT, DELETE, etc.
allow_headers=["*"], # Allow all headers
Expand Down
1 change: 1 addition & 0 deletions src/models/accesskey.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ class AccessKey(BaseModel):
expiry_date: datetime | None
created: datetime | None
last_use: datetime | None
view_once: bool = True
81 changes: 76 additions & 5 deletions src/routes/agents.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import logging
from datetime import datetime, timedelta
from typing import Annotated

Expand All @@ -10,7 +11,7 @@
from src.globals import access_service, agent_dao, auth_service, user_dao
from src.llm import list_llm_models
from src.models.accesskey import AccessKey
from src.models.agent import Agent
from src.models.agent import Agent, Role
from src.models.errors.embedding_error import EmbeddingAPIError, EmbeddingError
from src.models.errors.llm_error import LLMAPIError
from src.models.model import Model
Expand All @@ -20,6 +21,7 @@

config = Config()
router = APIRouter()
logger = logging.getLogger(__name__)


def _auth_disabled() -> bool:
Expand Down Expand Up @@ -136,6 +138,25 @@ class ProviderKeyRequest(BaseModel):
api_key: str


class ExternalAgentInfo(BaseModel):
agent_id: str
name: str
roles: list[Role]


def _access_key_valid_for_agent(agent: Agent, access_key: str | None) -> bool:
normalized_key = access_key.strip() if access_key else None
if not normalized_key:
return False

now = datetime.now()
for stored_key in agent.access_key:
if stored_key.key != normalized_key:
continue
return stored_key.expiry_date is None or now < stored_key.expiry_date
return False


def _map_embedding_api_error(error: EmbeddingAPIError) -> int:
message = str(error.original_error).lower() if error.original_error else ""
if any(keyword in message for keyword in ("quota", "rate limit", "429")):
Expand Down Expand Up @@ -426,6 +447,54 @@ def agent_info(
return agent


@router.get("/agent-info-by-accesskey", response_model=ExternalAgentInfo)
def agent_info_by_access_key(
access_key: Annotated[str | None, Header()],
):
"""Resolve safe agent metadata from an external access key.

This endpoint is intended for unauthenticated external clients. The access
key itself is the authorization credential, so the response intentionally
excludes model configuration, provider API keys, and access-key records.
"""
agents = agent_dao.get_agents()
total_keys = 0
active_keys = 0
expired_keys = 0
has_header = bool(access_key and access_key.strip())
now = datetime.now()

for agent in agents:
total_keys += len(agent.access_key)
for stored_key in agent.access_key:
if stored_key.expiry_date is None or now < stored_key.expiry_date:
active_keys += 1
else:
expired_keys += 1
if _access_key_valid_for_agent(agent, access_key):
return ExternalAgentInfo(
agent_id=agent.id or "",
name=agent.name,
roles=agent.roles,
)

logger.warning(
"External access-key lookup failed. Header present: %s, agents scanned: %s, keys scanned: %s, active keys: %s, expired keys: %s",
has_header,
len(agents),
total_keys,
active_keys,
expired_keys,
)
raise HTTPException(
status_code=401,
detail=(
"Access key not valid. Ensure you are using the full key value shown "
"when the key was created, not the key name or key id."
),
)


# TODO : implement a better system of returning status codes on exceptions


Expand All @@ -434,18 +503,19 @@ def new_access_key(
name: str,
agent_id: str,
expiry_date: str | None = None,
view_once: bool = True,
authorize: Annotated[AuthJWT | None, Depends(optional_auth)] = None,
):
_ensure_agent_access(authorize, agent_id)
try:
if expiry_date is None:
return access_service.generate_accesskey(name, None, agent_id)
return access_service.generate_accesskey(name, None, agent_id, view_once)
else:
expiry_date_formatted = datetime.fromisoformat(expiry_date).replace(
tzinfo=None
)
return access_service.generate_accesskey(
name, expiry_date_formatted, agent_id
name, expiry_date_formatted, agent_id, view_once
)

except HTTPException:
Expand Down Expand Up @@ -478,9 +548,10 @@ def get_access_keys(
raise HTTPException(
status_code=404, detail=f" agent of id not found {agent_id}"
)
access_keys = agent.access_key
access_keys = [access_key.model_copy() for access_key in agent.access_key]
for access_key in access_keys:
access_key.key = None
if access_key.view_once:
access_key.key = None
return access_keys


Expand Down
Loading