pdf-quiz-generator/backend/app/config.py
Daniel 7f36e07af0
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Failing after 33s
Tests / e2e (push) Failing after 34s
feat: SSO reads roles from the provider's groups, and needs a verified address
Everybody arriving through SSO landed as a learner and was promoted by
hand. That is fine for three people and wrong for three hundred: the
list of who may edit the bank then lives in two places and only one of
them is maintained. OIDC_ROLE_CLAIM names the claim that carries somebody's
groups ("groups" for most providers, "roles" for Entra app roles), and
OIDC_ADMIN_GROUPS / OIDC_MODERATOR_GROUPS say which values mean what.
All three shapes a provider might send are read — a list, a
space-separated string, a comma-separated one — and matched case-blind.

Applied on every sign-in, not only at creation, because a group somebody
can be added to and never removed from is not a list anybody can rely
on. The one demotion it refuses is the last administrator: a mistyped
group name should not lock everybody out of the settings page,
including the person who could fix the group name. It is logged when it
happens.

Blank OIDC_ROLE_CLAIM is the old behaviour exactly: role "user", set
once, never touched.

And an address the provider will not vouch for is not an identity. This
matches on email, so an explicit email_verified:false would hand an
existing account to whoever typed its address at a careless provider.
Refused now — only on an explicit false, since a provider that omits the
claim is not asserting anything either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 05:20:52 +02:00

109 lines
4.7 KiB
Python

from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
DATABASE_URL: str = "sqlite:///./quiz.db"
SECRET_KEY: str = "change-me-to-a-random-secret-key-in-production"
# Guessing a password: how many tries from one address, over how long.
# Cleared by a correct password, so this counts guesses rather than people.
LOGIN_MAX_ATTEMPTS: int = 10
LOGIN_WINDOW_MINUTES: int = 15
# Refresh is flood-protected rather than rate-limited; see auth.refresh.
REFRESH_MAX_PER_HOUR: int = 600
# Where the indexed clinical library answers, when there is one. The admin
# page can override it; this is the default a deployment ships with.
CLINICAL_MCP_URL: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
REDIS_URL: str = "redis://localhost:6379/0"
LITELLM_MODEL: str = "gpt-4o-mini"
LITELLM_API_KEY: str = ""
LITELLM_API_BASE: str = ""
LITELLM_EMBEDDING_MODEL: str = ""
# Cross-encoder reranker, named as the proxy serves it. Blank turns
# reranking off and leaves every result list in the order rank fusion
# produced, which is what a deployment without this proxy gets.
LITELLM_RERANK_MODEL: str = "cohere-rerank-v4.0-pro"
OPENAI_API_KEY: str = ""
ELEVENLABS_API_KEY: str = ""
GOOGLE_TTS_API_KEY: str = ""
LOCAL_SPEECH_GATEWAY_URL: str = "http://127.0.0.1:8110"
AWS_ACCESS_KEY_ID: str = ""
AWS_SECRET_ACCESS_KEY: str = ""
AWS_REGION: str = "us-east-1"
AWS_BEDROCK_REGION: str = "us-east-1"
EMBEDDING_DIMENSIONS: int = 1024
APP_URL: str = "https://quiz.danvics.com"
CHROMA_PERSIST_DIR: str = "./chroma_data"
MAIL_USERNAME: str = ""
MAIL_PASSWORD: str = ""
MAIL_FROM: str = ""
MAIL_PORT: int = 587
MAIL_SERVER: str = "smtp.gmail.com"
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
UPLOAD_DIR: str = "./uploads"
# local | s3. Reads fall back to the volume either way, so existing uploads
# keep working and files can migrate gradually.
STORAGE_BACKEND: str = "local"
S3_ENDPOINT_URL: str = "http://minio:9000"
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_BUCKET: str = "pedshub-media"
S3_REGION: str = "us-east-1"
MAX_UPLOAD_SIZE: int = 524288000 # 500MB
# hCaptcha. Leave the secret blank to disable the challenge entirely.
# Cap, self-hosted beside us. The secret verifies a solve and never leaves
# the server; the site key is public and names the widget's endpoint.
CAP_API_URL: str = "http://cap:3000"
CAP_SECRET_KEY: str = ""
# The browser gets its own copy of the site key from the frontend
# container, which is a separate image with a separate .env. This one is
# here so an operator can keep both halves of the pair together and see
# at a glance which widget the secret belongs to.
CAP_SITE_KEY: str = ""
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Set it and a first admin is created with this address
# Leave blank and one is generated and printed to the log at first start:
# docker compose logs backend | grep -A3 "FIRST ADMIN"
DEFAULT_ADMIN_PASSWORD: str = ""
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret
# OIDC / SSO — leave blank to disable
OIDC_PROVIDER_URL: str = "" # e.g. https://accounts.google.com, https://login.microsoftonline.com/{tenant}/v2.0
OIDC_CLIENT_ID: str = ""
OIDC_CLIENT_SECRET: str = ""
OIDC_SCOPES: str = "openid email profile" # space-separated
OIDC_PROVIDER_NAME: str = "SSO" # Display name on login button
# Which claim carries the groups or roles a person belongs to at the
# provider — "groups" for most, "roles" for Entra app roles. Blank means
# no mapping: everybody who signs in through SSO is a learner, which is
# the safe default and was the only behaviour there was.
OIDC_ROLE_CLAIM: str = ""
# Values in that claim which mean admin, and which mean moderator.
# Comma-separated, matched case-insensitively. Admin wins over moderator.
OIDC_ADMIN_GROUPS: str = ""
OIDC_MODERATOR_GROUPS: str = ""
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
# Clinical library index (Milvus on the ped-ai stack), read-only. Articles
# are grounded in what it retrieves; nothing here writes to it.
CLINICAL_MILVUS_URI: str = ""
CLINICAL_MILVUS_TOKEN: str = ""
CLINICAL_MILVUS_COLLECTION: str = "mcp_bge_m3_1024"
settings = Settings()