Rename the import package app to fluksio
A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import secrets
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
AnyUrl,
|
||||
BeforeValidator,
|
||||
EmailStr,
|
||||
HttpUrl,
|
||||
PostgresDsn,
|
||||
computed_field,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
def parse_cors(v: Any) -> list[str] | str:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",") if i.strip()]
|
||||
elif isinstance(v, list | str):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
# Use top level .env file (one level above ./backend/)
|
||||
env_file="../.env",
|
||||
env_ignore_empty=True,
|
||||
extra="ignore",
|
||||
)
|
||||
API_V1_STR: str = "/api/v1"
|
||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
# 60 minutes * 24 hours * 8 days = 8 days
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||
FRONTEND_HOST: str = "http://localhost:5173"
|
||||
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
||||
|
||||
# Flows live on disk as a git repository; secrets stay outside it.
|
||||
FLOWS_DIR: Path = Path("flow-data/flows")
|
||||
SECRETS_FILE: Path = Path("flow-data/secrets.enc")
|
||||
# Which failures reach which channel. Beside the flows, not in them:
|
||||
# alerting is the deployment's concern, not any one flow's.
|
||||
ALERTS_FILE: Path = Path("flow-data/alerts.json")
|
||||
# Which dashboards each device shows. Beside the flows for the same reason
|
||||
# alerting is: where a screen hangs is the deployment's concern rather than
|
||||
# any one dashboard's.
|
||||
PANELS_FILE: Path = Path("flow-data/panels.json")
|
||||
# The MCP endpoint, and the OAuth server agents authenticate against. Off
|
||||
# until someone asks for it: it opens client registration to the network.
|
||||
MCP_ENABLED: bool = False
|
||||
# Unauthenticated test-only endpoints (user seeding). Requires an explicit
|
||||
# opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the
|
||||
# default environment never exposes them.
|
||||
PRIVATE_API_ENABLED: bool = False
|
||||
DOMAIN: str = "localhost"
|
||||
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
|
||||
# Written only when someone enrols this installation with a portal.
|
||||
# Its absence is what keeps remote access off.
|
||||
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
|
||||
OAUTH_CODE_EXPIRE_SECONDS: int = 60
|
||||
# Short, because an agent's token is a bearer secret held by a program
|
||||
# rather than a person, and it can refresh unattended.
|
||||
MCP_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
MCP_REFRESH_EXPIRE_DAYS: int = 30
|
||||
FLOW_MAX_WORKERS: int = 4
|
||||
# How long a python node may run before its worker is killed, unless the
|
||||
# node sets its own. Long enough for a slow HTTP call, short enough that a
|
||||
# runaway loop is not a wedged flow.
|
||||
FLOW_NODE_TIMEOUT: float = 30.0
|
||||
# How long the engine's own metrics, events and run records are kept.
|
||||
OBS_RETENTION_DAYS: int = 30
|
||||
# Without a Redis host the engine keeps its state in memory.
|
||||
REDIS_HOST: str | None = None
|
||||
REDIS_PORT: int = 6379
|
||||
|
||||
BACKEND_CORS_ORIGINS: Annotated[
|
||||
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
||||
] = []
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def oauth_issuer(self) -> str:
|
||||
"""Who issues MCP tokens — this app, on its API host.
|
||||
|
||||
Kept separate from the app's own URL because a hosted deployment can
|
||||
later point agents at a different issuer without the resource server
|
||||
changing: it validates whatever issuer it is configured to trust.
|
||||
"""
|
||||
scheme = "http" if self.ENVIRONMENT == "local" else "https"
|
||||
return f"{scheme}://api.{self.DOMAIN}"
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def mcp_resource(self) -> str:
|
||||
"""The resource an MCP token is issued for (RFC 8707)."""
|
||||
return f"{self.oauth_issuer}/mcp"
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def all_cors_origins(self) -> list[str]:
|
||||
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
|
||||
self.FRONTEND_HOST
|
||||
]
|
||||
|
||||
PROJECT_NAME: str
|
||||
SENTRY_DSN: HttpUrl | None = None
|
||||
POSTGRES_SERVER: str
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str
|
||||
POSTGRES_PASSWORD: str = ""
|
||||
POSTGRES_DB: str = ""
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
|
||||
return PostgresDsn.build(
|
||||
scheme="postgresql+psycopg",
|
||||
username=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=self.POSTGRES_SERVER,
|
||||
port=self.POSTGRES_PORT,
|
||||
path=self.POSTGRES_DB,
|
||||
)
|
||||
|
||||
SMTP_TLS: bool = True
|
||||
SMTP_SSL: bool = False
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_USER: str | None = None
|
||||
SMTP_PASSWORD: str | None = None
|
||||
EMAILS_FROM_EMAIL: EmailStr | None = None
|
||||
EMAILS_FROM_NAME: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _set_default_emails_from(self) -> Self:
|
||||
if not self.EMAILS_FROM_NAME:
|
||||
self.EMAILS_FROM_NAME = self.PROJECT_NAME
|
||||
return self
|
||||
|
||||
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def emails_enabled(self) -> bool:
|
||||
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
|
||||
|
||||
EMAIL_TEST_USER: EmailStr = "test@example.com"
|
||||
FIRST_SUPERUSER: EmailStr
|
||||
FIRST_SUPERUSER_PASSWORD: str
|
||||
|
||||
def _check_default_secret(self, var_name: str, value: str | None) -> None:
|
||||
if value == "changethis":
|
||||
message = (
|
||||
f'The value of {var_name} is "changethis", '
|
||||
"for security, please change it, at least for deployments."
|
||||
)
|
||||
if self.ENVIRONMENT == "local":
|
||||
warnings.warn(message, stacklevel=1)
|
||||
else:
|
||||
raise ValueError(message)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_non_default_secrets(self) -> Self:
|
||||
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
||||
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
|
||||
self._check_default_secret(
|
||||
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings() # type: ignore
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlmodel import Session, create_engine, select
|
||||
|
||||
from fluksio import crud
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.models import User, UserCreate
|
||||
|
||||
# A connection idle across a Postgres restart is dead but still pooled; the
|
||||
# pre-ping spends a round trip to find out instead of failing the request.
|
||||
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI), pool_pre_ping=True)
|
||||
|
||||
|
||||
# make sure all SQLModel models are imported (app.models) before initializing DB
|
||||
# otherwise, SQLModel might fail to initialize relationships properly
|
||||
# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28
|
||||
|
||||
|
||||
def init_db(session: Session) -> None:
|
||||
# Tables should be created with Alembic migrations
|
||||
# But if you don't want to use migrations, create
|
||||
# the tables un-commenting the next lines
|
||||
# from sqlmodel import SQLModel
|
||||
|
||||
# This works because the models are already imported and registered from app.models
|
||||
# SQLModel.metadata.create_all(engine)
|
||||
|
||||
user = session.exec(
|
||||
select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
).first()
|
||||
if not user:
|
||||
user_in = UserCreate(
|
||||
email=settings.FIRST_SUPERUSER,
|
||||
password=settings.FIRST_SUPERUSER_PASSWORD,
|
||||
is_superuser=True,
|
||||
)
|
||||
user = crud.create_user(session=session, user_create=user_in)
|
||||
@@ -0,0 +1,232 @@
|
||||
import base64
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||
|
||||
from fluksio.core.config import settings
|
||||
|
||||
password_hash = PasswordHash(
|
||||
(
|
||||
Argon2Hasher(),
|
||||
BcryptHasher(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
#: MCP tokens are signed with a keypair of their own, so the public half can be
|
||||
#: published and the whole set revoked by rotating it — without logging anyone
|
||||
#: out of the browser, and without the resource server needing the secret that
|
||||
#: signs browser sessions.
|
||||
OAUTH_ALGORITHM = "RS256"
|
||||
#: The one scope an MCP token carries.
|
||||
MCP_SCOPE = "mcp"
|
||||
#: What a remote worker's credential says it is for. Its own audience, so an
|
||||
#: agent's token cannot attach a worker and a worker's cannot call the API.
|
||||
WORKER_SCOPE = "worker"
|
||||
WORKER_AUDIENCE = "fluksio-worker"
|
||||
#: What a paired wall panel's credential says it is for. Its own audience, so
|
||||
#: the session decode refuses it outright and the scope check in ``deps`` is
|
||||
#: the only door it fits.
|
||||
PANEL_AUDIENCE = "fluksio-panel"
|
||||
|
||||
|
||||
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OAuth signing key
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_or_create_key() -> rsa.RSAPrivateKey:
|
||||
"""The RSA key MCP tokens are signed with, generated on first use."""
|
||||
path = settings.OAUTH_PRIVATE_KEY_FILE
|
||||
if path.exists():
|
||||
loaded = serialization.load_pem_private_key(path.read_bytes(), password=None)
|
||||
if not isinstance(loaded, rsa.RSAPrivateKey):
|
||||
raise TypeError(f"{path} is not an RSA private key")
|
||||
return loaded
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(
|
||||
key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
path.chmod(0o600)
|
||||
return key
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def oauth_key() -> rsa.RSAPrivateKey:
|
||||
return _load_or_create_key()
|
||||
|
||||
|
||||
def public_jwks() -> dict[str, Any]:
|
||||
"""The public half, for anything that wants to check a token itself."""
|
||||
numbers = oauth_key().public_key().public_numbers()
|
||||
|
||||
def b64(value: int) -> str:
|
||||
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": OAUTH_ALGORITHM,
|
||||
"kid": "fluksio-oauth",
|
||||
"n": b64(numbers.n),
|
||||
"e": b64(numbers.e),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def create_oauth_access_token(
|
||||
user_id: uuid.UUID, client_id: uuid.UUID, expires_delta: timedelta
|
||||
) -> str:
|
||||
"""An access token for the MCP channel, told apart from a browser session.
|
||||
|
||||
The ``mcp`` claim is what the MCP endpoint checks: a perfectly valid token
|
||||
from someone's browser is refused there, so agent traffic never arrives
|
||||
looking like a person's.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"iss": settings.oauth_issuer,
|
||||
"aud": settings.mcp_resource,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
"mcp": True,
|
||||
"client_id": str(client_id),
|
||||
"scope": MCP_SCOPE,
|
||||
}
|
||||
return jwt.encode(
|
||||
payload,
|
||||
oauth_key().private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
),
|
||||
algorithm=OAUTH_ALGORITHM,
|
||||
headers={"kid": "fluksio-oauth"},
|
||||
)
|
||||
|
||||
|
||||
def create_worker_token(name: str, expires_delta: timedelta) -> str:
|
||||
"""A credential a remote worker presents when it dials in.
|
||||
|
||||
Signed with the same keypair the agent tokens use, so the whole set can be
|
||||
revoked by rotating one key, and told apart from them by its audience: a
|
||||
worker's token grants no API access, and an agent's opens no worker
|
||||
connection.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": name,
|
||||
"iss": settings.oauth_issuer,
|
||||
"aud": WORKER_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
"scope": WORKER_SCOPE,
|
||||
}
|
||||
return jwt.encode(
|
||||
payload,
|
||||
oauth_key().private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
),
|
||||
algorithm=OAUTH_ALGORITHM,
|
||||
headers={"kid": "fluksio-oauth"},
|
||||
)
|
||||
|
||||
|
||||
def decode_worker_token(token: str) -> dict[str, Any]:
|
||||
"""Validate a worker credential. Raises ``InvalidTokenError`` if it does not."""
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
oauth_key().public_key(),
|
||||
algorithms=[OAUTH_ALGORITHM],
|
||||
audience=WORKER_AUDIENCE,
|
||||
issuer=settings.oauth_issuer,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def create_panel_token(
|
||||
panel: str, user_id: uuid.UUID | str, expires_delta: timedelta
|
||||
) -> str:
|
||||
"""The credential a paired wall panel holds.
|
||||
|
||||
Signed with the app's own secret like a browser session, because it names a
|
||||
person in exactly the same way: ``sub`` is the account that approved the
|
||||
pairing, so everything the panel does is attributable to them. What keeps
|
||||
it from being a full session is the ``panel`` claim — the request filter in
|
||||
``fluksio.api.deps`` lets it reach only that panel's dashboards and the message
|
||||
endpoints its widgets need.
|
||||
|
||||
Long-lived on purpose: a wall tablet is set up once and left running, and
|
||||
it has no keyboard to log in again with.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"aud": PANEL_AUDIENCE,
|
||||
"panel": panel,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
}
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_panel_token(token: str) -> dict[str, Any]:
|
||||
"""Validate a panel credential. Raises ``InvalidTokenError`` if it does not."""
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[ALGORITHM],
|
||||
audience=PANEL_AUDIENCE,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def decode_oauth_token(token: str) -> dict[str, Any]:
|
||||
"""Validate an MCP token. Raises ``InvalidTokenError`` if it does not hold."""
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
token,
|
||||
oauth_key().public_key(),
|
||||
algorithms=[OAUTH_ALGORITHM],
|
||||
audience=settings.mcp_resource,
|
||||
issuer=settings.oauth_issuer,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def verify_password(
|
||||
plain_password: str, hashed_password: str
|
||||
) -> tuple[bool, str | None]:
|
||||
return password_hash.verify_and_update(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return password_hash.hash(password)
|
||||
Reference in New Issue
Block a user