feat: use pydantic to validate the configuration

This commit is contained in:
Éloi Rivard 2023-12-18 18:06:03 +01:00
parent 731016d7f3
commit 8625318341
No known key found for this signature in database
GPG key ID: 7EDA204EA57DD184
76 changed files with 1492 additions and 2124 deletions

View file

@ -1,11 +1,14 @@
Unreleased
----------
🚨Configuration files must be updated.🚨
Added
^^^^^
- Add `created` and `last_modified` datetime for all models
- Sitemap to the documentation :pr:`169`
- Configuration management with pydantic-settings :issue:`138` :pr:`170`
Changed
^^^^^^^

View file

@ -11,7 +11,7 @@ csrf = CSRFProtect()
def setup_sentry(app): # pragma: no cover
if not app.config.get("SENTRY_DSN"):
if not app.config["CANAILLE"]["SENTRY_DSN"]:
return None
try:
@ -21,12 +21,14 @@ def setup_sentry(app): # pragma: no cover
except Exception:
return None
sentry_sdk.init(dsn=app.config["SENTRY_DSN"], integrations=[FlaskIntegration()])
sentry_sdk.init(
dsn=app.config["CANAILLE"]["SENTRY_DSN"], integrations=[FlaskIntegration()]
)
return sentry_sdk
def setup_logging(app):
conf = app.config.get("LOGGING")
conf = app.config["CANAILLE"]["LOGGING"]
if conf is None:
log_level = "DEBUG" if app.debug else "INFO"
dictConfig(
@ -72,7 +74,7 @@ def setup_blueprints(app):
app.register_blueprint(canaille.core.endpoints.bp)
if "OIDC" in app.config:
if "CANAILLE_OIDC" in app.config:
import canaille.oidc.endpoints
app.register_blueprint(canaille.oidc.endpoints.bp)
@ -92,19 +94,24 @@ def setup_flask(app):
return {
"debug": app.debug or app.config.get("TESTING", False),
"has_smtp": "SMTP" in app.config,
"has_oidc": "OIDC" in app.config,
"has_password_recovery": app.config.get("ENABLE_PASSWORD_RECOVERY", True),
"has_registration": app.config.get("ENABLE_REGISTRATION", False),
"has_smtp": "SMTP" in app.config["CANAILLE"],
"has_oidc": "CANAILLE_OIDC" in app.config["CANAILLE"],
"has_password_recovery": app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"],
"has_registration": app.config["CANAILLE"]["ENABLE_REGISTRATION"],
"has_account_lockability": app.backend.get().has_account_lockability(),
"logo_url": app.config.get("LOGO"),
"favicon_url": app.config.get("FAVICON", app.config.get("LOGO")),
"website_name": app.config.get("NAME", "Canaille"),
"logo_url": app.config["CANAILLE"]["LOGO"],
"favicon_url": app.config["CANAILLE"]["FAVICON"]
or app.config["CANAILLE"]["LOGO"],
"website_name": app.config["CANAILLE"]["NAME"],
"user": current_user(),
"menu": True,
"is_boosted": request.headers.get("HX-Boosted", False),
"has_email_confirmation": app.config.get("EMAIL_CONFIRMATION") is True
or (app.config.get("EMAIL_CONFIRMATION") is None and "SMTP" in app.config),
"has_email_confirmation": app.config["CANAILLE"]["EMAIL_CONFIRMATION"]
is True
or (
app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None
and "SMTP" in app.config["CANAILLE"]
),
}
@ -137,7 +144,7 @@ def create_app(config=None, validate=True, backend=None):
setup_themer(app)
setup_flask(app)
if "OIDC" in app.config:
if "CANAILLE_OIDC" in app.config:
from .oidc.oauth import setup_oauth
setup_oauth(app)

View file

@ -25,7 +25,7 @@ def build_hash(*args):
def default_fields():
read = set()
write = set()
for acl in current_app.config.get("ACL", {}).values():
for acl in current_app.config["CANAILLE"]["ACL"].values():
if not acl.get("FILTER"):
read |= set(acl.get("READ", []))
write |= set(acl.get("WRITE", []))
@ -41,8 +41,8 @@ def get_current_domain():
def get_current_mail_domain():
if current_app.config["SMTP"].get("FROM_ADDR"):
return current_app.config["SMTP"]["FROM_ADDR"].split("@")[-1]
if current_app.config["CANAILLE"]["SMTP"]["FROM_ADDR"]:
return current_app.config["CANAILLE"]["SMTP"]["FROM_ADDR"].split("@")[-1]
return get_current_domain().split(":")[0]

View file

@ -2,43 +2,103 @@ import os
import smtplib
import socket
import sys
from collections.abc import Mapping
from typing import Optional
from flask import current_app
from pydantic import create_model
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
from canaille.app.mails import DEFAULT_SMTP_HOST
from canaille.app.mails import DEFAULT_SMTP_PORT
from canaille.core.configuration import CoreSettings
ROOT = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
class RootSettings(BaseSettings):
"""The top-level namespace contains holds the configuration settings
unrelated to Canaille. The configuration paramateres from the following
libraries can be used:
- :doc:`Flask <flask:config>`
- :doc:`Flask-WTF <flask-wtf:config>`
- :doc:`Flask-Babel <flask-babel:index>`
- :doc:`Authlib <authlib:flask/2/authorization-server>`
"""
model_config = SettingsConfigDict(
extra="allow",
env_nested_delimiter="__",
case_sensitive=True,
env_file=".env",
)
SECRET_KEY: str
"""The Flask :external:py:data:`SECRET_KEY` configuration setting.
You MUST change this.
"""
SERVER_NAME: Optional[str] = None
"""The Flask :external:py:data:`SERVER_NAME` configuration setting.
This sets domain name on which canaille will be served.
"""
PREFERRED_URL_SCHEME: str = "https"
"""The Flask :external:py:data:`PREFERRED_URL_SCHEME` configuration
setting.
This sets the url scheme by which canaille will be served.
"""
DEBUG: bool = False
"""The Flask :external:py:data:`DEBUG` configuration setting.
This enables debug options. This is useful for development but
should be absolutely avoided in production environments.
"""
def settings_factory(config):
"""Overly complicated function that pushes the backend specific
configuration into CoreSettings, in the purpose break dependency against
backends libraries like python-ldap or sqlalchemy."""
attributes = {"CANAILLE": (Optional[CoreSettings], None)}
if "CANAILLE_SQL" in config or any(
var.startswith("CANAILLE_SQL__") for var in os.environ
):
from canaille.backends.sql.configuration import SQLSettings
attributes["CANAILLE_SQL"] = (Optional[SQLSettings], None)
if "CANAILLE_LDAP" in config or any(
var.startswith("CANAILLE__LDAP__") for var in os.environ
):
from canaille.backends.ldap.configuration import LDAPSettings
attributes["CANAILLE_LDAP"] = (Optional[LDAPSettings], None)
if "CANAILLE_OIDC" in config or any(
var.startswith("CANAILLE_OIDC__") for var in os.environ
):
from canaille.oidc.configuration import OIDCSettings
attributes["CANAILLE_OIDC"] = (Optional[OIDCSettings], None)
Settings = create_model(
"Settings",
__base__=RootSettings,
**attributes,
)
return Settings(**config, _secrets_dir=os.environ.get("SECRETS_DIR"))
class ConfigurationException(Exception):
pass
def parse_file_keys(config):
"""Replaces configuration entries with the '_FILE' suffix with the matching
file content."""
SUFFIX = "_FILE"
new_config = {}
for key, value in config.items():
if isinstance(value, Mapping):
new_config[key] = parse_file_keys(value)
elif isinstance(key, str) and key.endswith(SUFFIX) and isinstance(value, str):
with open(value) as f:
value = f.read().rstrip("\n")
root_key = key[: -len(SUFFIX)]
new_config[root_key] = value
else:
new_config[key] = value
return new_config
def toml_content(file_path):
try:
if sys.version_info < (3, 11): # pragma: no cover
@ -55,7 +115,7 @@ def toml_content(file_path):
raise Exception("toml library not installed. Cannot load configuration.")
def setup_config(app, config=None, validate_config=True):
def setup_config(app, config=None, test_config=True):
from canaille.oidc.installation import install
app.config.from_mapping(
@ -65,29 +125,22 @@ def setup_config(app, config=None, validate_config=True):
"OAUTH2_ACCESS_TOKEN_GENERATOR": "canaille.oidc.oauth.generate_access_token",
}
)
if not config and "CONFIG" in os.environ:
config = toml_content(os.environ.get("CONFIG"))
if config:
app.config.from_mapping(parse_file_keys(config))
elif "CONFIG" in os.environ:
app.config.from_mapping(parse_file_keys(toml_content(os.environ["CONFIG"])))
else:
raise Exception(
"No configuration file found. "
"Either create conf/config.toml or set the 'CONFIG' variable environment."
)
config_obj = settings_factory(config)
app.config.from_mapping(config_obj.model_dump())
if app.debug:
install(app.config, debug=True)
if validate_config:
if test_config:
validate(app.config)
def validate(config, validate_remote=False):
validate_keypair(config)
validate_theme(config)
validate_keypair(config.get("CANAILLE_OIDC"))
validate_theme(config["CANAILLE"])
if not validate_remote:
return
@ -95,39 +148,39 @@ def validate(config, validate_remote=False):
from canaille.backends import BaseBackend
BaseBackend.get().validate(config)
validate_smtp_configuration(config)
validate_smtp_configuration(config["CANAILLE"]["SMTP"])
def validate_keypair(config):
if (
config.get("OIDC")
and config["OIDC"].get("JWT")
and not config["OIDC"]["JWT"].get("PUBLIC_KEY")
config
and config["JWT"]
and not config["JWT"]["PUBLIC_KEY"]
and not current_app.debug
):
raise ConfigurationException("No public key has been set")
if (
config.get("OIDC")
and config["OIDC"].get("JWT")
and not config["OIDC"]["JWT"].get("PRIVATE_KEY")
config
and config["JWT"]
and not config["JWT"]["PRIVATE_KEY"]
and not current_app.debug
):
raise ConfigurationException("No private key has been set")
def validate_smtp_configuration(config):
host = config["SMTP"].get("HOST", DEFAULT_SMTP_HOST)
port = config["SMTP"].get("PORT", DEFAULT_SMTP_PORT)
host = config["HOST"]
port = config["PORT"]
try:
with smtplib.SMTP(host=host, port=port) as smtp:
if config["SMTP"].get("TLS"):
if config["TLS"]:
smtp.starttls()
if config["SMTP"].get("LOGIN"):
if config["LOGIN"]:
smtp.login(
user=config["SMTP"]["LOGIN"],
password=config["SMTP"].get("PASSWORD"),
user=config["LOGIN"],
password=config["PASSWORD"],
)
except (socket.gaierror, ConnectionRefusedError) as exc:
raise ConfigurationException(
@ -136,7 +189,7 @@ def validate_smtp_configuration(config):
except smtplib.SMTPAuthenticationError as exc:
raise ConfigurationException(
f'SMTP authentication failed with user \'{config["SMTP"]["LOGIN"]}\''
f'SMTP authentication failed with user \'{config["LOGIN"]}\''
) from exc
except smtplib.SMTPNotSupportedError as exc:
@ -144,7 +197,7 @@ def validate_smtp_configuration(config):
def validate_theme(config):
if not config.get("THEME"):
if not config or not config["THEME"]:
return
if not os.path.exists(config["THEME"]) and not os.path.exists(

View file

@ -91,7 +91,7 @@ def smtp_needed():
def wrapper(view_function):
@wraps(view_function)
def decorator(*args, **kwargs):
if "SMTP" in current_app.config:
if "SMTP" in current_app.config["CANAILLE"]:
return view_function(*args, **kwargs)
message = _("No SMTP server has been configured")

View file

@ -53,8 +53,8 @@ def locale_selector():
if user is not None and user.preferred_language in available_language_codes:
return user.preferred_language
if current_app.config.get("LANGUAGE"):
return current_app.config.get("LANGUAGE")
if current_app.config["CANAILLE"]["LANGUAGE"]:
return current_app.config["CANAILLE"]["LANGUAGE"]
return request.accept_languages.best_match(available_language_codes)
@ -67,7 +67,7 @@ def timezone_selector():
from babel.dates import LOCALTZ
try:
return pytz.timezone(current_app.config.get("TIMEZONE"))
return pytz.timezone(current_app.config["CANAILLE"]["TIMEZONE"])
except pytz.exceptions.UnknownTimeZoneError:
return LOCALTZ

View file

@ -10,14 +10,9 @@ from flask import request
from canaille.app import get_current_domain
from canaille.app import get_current_mail_domain
DEFAULT_SMTP_HOST = "localhost"
DEFAULT_SMTP_PORT = 25
DEFAULT_SMTP_TLS = False
DEFAULT_SMTP_SSL = False
def logo():
logo_url = current_app.config.get("LOGO")
logo_url = current_app.config["CANAILLE"]["LOGO"]
if not logo_url:
return None, None, None
@ -52,8 +47,8 @@ def send_email(subject, recipient, text, html, attachements=None):
msg["Subject"] = subject
msg["To"] = f"<{recipient}>"
name = current_app.config.get("NAME", "Canaille")
address = current_app.config["SMTP"].get("FROM_ADDR")
name = current_app.config["CANAILLE"]["NAME"]
address = current_app.config["CANAILLE"]["SMTP"]["FROM_ADDR"]
if not address:
domain = get_current_mail_domain()
@ -76,19 +71,19 @@ def send_email(subject, recipient, text, html, attachements=None):
try:
connection_func = (
smtplib.SMTP_SSL
if current_app.config["SMTP"].get("SSL", DEFAULT_SMTP_SSL)
if current_app.config["CANAILLE"]["SMTP"]["SSL"]
else smtplib.SMTP
)
with connection_func(
host=current_app.config["SMTP"].get("HOST", DEFAULT_SMTP_HOST),
port=current_app.config["SMTP"].get("PORT", DEFAULT_SMTP_PORT),
host=current_app.config["CANAILLE"]["SMTP"]["HOST"],
port=current_app.config["CANAILLE"]["SMTP"]["PORT"],
) as smtp:
if current_app.config["SMTP"].get("TLS", DEFAULT_SMTP_TLS):
if current_app.config["CANAILLE"]["SMTP"]["TLS"]:
smtp.starttls()
if current_app.config["SMTP"].get("LOGIN"):
if current_app.config["CANAILLE"]["SMTP"]["LOGIN"]:
smtp.login(
user=current_app.config["SMTP"].get("LOGIN"),
password=current_app.config["SMTP"].get("PASSWORD"),
user=current_app.config["CANAILLE"]["SMTP"]["LOGIN"],
password=current_app.config["CANAILLE"]["SMTP"]["PASSWORD"],
)
smtp.send_message(msg)

View file

@ -12,9 +12,10 @@ if flask_themer:
render_template = flask_themer.render_template
def setup_themer(app):
theme_config = app.config["CANAILLE"]["THEME"]
additional_themes_dir = (
os.path.abspath(os.path.dirname(app.config["THEME"]))
if app.config.get("THEME") and os.path.exists(app.config["THEME"])
os.path.abspath(os.path.dirname(theme_config))
if theme_config and os.path.exists(theme_config)
else None
)
themer = flask_themer.Themer(
@ -26,8 +27,8 @@ if flask_themer:
@themer.current_theme_loader
def get_current_theme():
# if config['THEME'] may be a theme name or an absolute path
return app.config.get("THEME", "default").split("/")[-1]
# if config['THEME'] may be a theme name or a path
return app.config["CANAILLE"]["THEME"].split("/")[-1]
@app.errorhandler(400)
def bad_request(error):

View file

@ -82,8 +82,20 @@ class BaseBackend:
def setup_backend(app, backend=None):
if not backend:
backend_names = list(app.config.get("BACKENDS", {"memory": {}}).keys())
backend_name = backend_names[0].lower()
prefix = "CANAILLE_"
available_backends_names = [
f"{prefix}{name}".upper() for name in available_backends()
]
configured_backend_names = [
key[len(prefix) :]
for key in app.config.keys()
if key in available_backends_names
]
backend_name = (
configured_backend_names[0].lower()
if configured_backend_names
else "memory"
)
module = importlib.import_module(f"canaille.backends.{backend_name}.backend")
backend_class = getattr(module, "Backend")
backend = backend_class(app.config)

View file

@ -17,10 +17,10 @@ from .utils import listify
@contextmanager
def ldap_connection(config):
conn = ldap.initialize(config["BACKENDS"]["LDAP"]["URI"])
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, config["BACKENDS"]["LDAP"].get("TIMEOUT"))
conn = ldap.initialize(config["CANAILLE_LDAP"]["URI"])
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, config["CANAILLE_LDAP"]["TIMEOUT"])
conn.simple_bind_s(
config["BACKENDS"]["LDAP"]["BIND_DN"], config["BACKENDS"]["LDAP"]["BIND_PW"]
config["CANAILLE_LDAP"]["BIND_DN"], config["CANAILLE_LDAP"]["BIND_PW"]
)
try:
@ -44,7 +44,7 @@ def install_schema(config, schema_path):
except ldap.INSUFFICIENT_ACCESS as exc:
raise InstallationException(
f"The user '{config['BACKENDS']['LDAP']['BIND_DN']}' has insufficient permissions to install LDAP schemas."
f"The user '{config['CANAILLE_LDAP']['BIND_DN']}' has insufficient permissions to install LDAP schemas."
) from exc
@ -80,26 +80,26 @@ class Backend(BaseBackend):
return self._connection
try:
self._connection = ldap.initialize(self.config["BACKENDS"]["LDAP"]["URI"])
self._connection = ldap.initialize(self.config["CANAILLE_LDAP"]["URI"])
self._connection.set_option(
ldap.OPT_NETWORK_TIMEOUT,
self.config["BACKENDS"]["LDAP"].get("TIMEOUT"),
self.config["CANAILLE_LDAP"]["TIMEOUT"],
)
self._connection.simple_bind_s(
self.config["BACKENDS"]["LDAP"]["BIND_DN"],
self.config["BACKENDS"]["LDAP"]["BIND_PW"],
self.config["CANAILLE_LDAP"]["BIND_DN"],
self.config["CANAILLE_LDAP"]["BIND_PW"],
)
except ldap.SERVER_DOWN as exc:
message = _("Could not connect to the LDAP server '{uri}'").format(
uri=self.config["BACKENDS"]["LDAP"]["URI"]
uri=self.config["CANAILLE_LDAP"]["URI"]
)
logging.error(message)
raise ConfigurationException(message) from exc
except ldap.INVALID_CREDENTIALS as exc:
message = _("LDAP authentication failed with user '{user}'").format(
user=self.config["BACKENDS"]["LDAP"]["BIND_DN"]
user=self.config["CANAILLE_LDAP"]["BIND_DN"]
)
logging.error(message)
raise ConfigurationException(message) from exc
@ -129,8 +129,8 @@ class Backend(BaseBackend):
except ldap.INSUFFICIENT_ACCESS as exc:
raise ConfigurationException(
f'LDAP user \'{config["BACKENDS"]["LDAP"]["BIND_DN"]}\' cannot create '
f'users at \'{config["BACKENDS"]["LDAP"]["USER_BASE"]}\''
f'LDAP user \'{config["CANAILLE_LDAP"]["BIND_DN"]}\' cannot create '
f'users at \'{config["CANAILLE_LDAP"]["USER_BASE"]}\''
) from exc
try:
@ -154,8 +154,8 @@ class Backend(BaseBackend):
except ldap.INSUFFICIENT_ACCESS as exc:
raise ConfigurationException(
f'LDAP user \'{config["BACKENDS"]["LDAP"]["BIND_DN"]}\' cannot create '
f'groups at \'{config["BACKENDS"]["LDAP"]["GROUP_BASE"]}\''
f'LDAP user \'{config["CANAILLE_LDAP"]["BIND_DN"]}\' cannot create '
f'groups at \'{config["CANAILLE_LDAP"]["GROUP_BASE"]}\''
) from exc
finally:
@ -163,9 +163,7 @@ class Backend(BaseBackend):
@classmethod
def login_placeholder(cls):
user_filter = current_app.config["BACKENDS"]["LDAP"].get(
"USER_FILTER", models.User.DEFAULT_FILTER
)
user_filter = current_app.config["CANAILLE_LDAP"]["USER_FILTER"]
placeholders = []
if "cn={{login" in user_filter.replace(" ", ""):
@ -193,30 +191,20 @@ def setup_ldap_models(config):
from .ldapobject import LDAPObject
LDAPObject.root_dn = config["BACKENDS"]["LDAP"]["ROOT_DN"]
LDAPObject.root_dn = config["CANAILLE_LDAP"]["ROOT_DN"]
user_base = config["BACKENDS"]["LDAP"]["USER_BASE"].replace(
f',{config["BACKENDS"]["LDAP"]["ROOT_DN"]}', ""
user_base = config["CANAILLE_LDAP"]["USER_BASE"].replace(
f',{config["CANAILLE_LDAP"]["ROOT_DN"]}', ""
)
models.User.base = user_base
models.User.rdn_attribute = config["BACKENDS"]["LDAP"].get(
"USER_RDN", models.User.DEFAULT_RDN
)
object_class = config["BACKENDS"]["LDAP"].get(
"USER_CLASS", models.User.DEFAULT_OBJECT_CLASS
)
models.User.rdn_attribute = config["CANAILLE_LDAP"]["USER_RDN"]
object_class = config["CANAILLE_LDAP"]["USER_CLASS"]
models.User.ldap_object_class = listify(object_class)
group_base = (
config["BACKENDS"]["LDAP"]
.get("GROUP_BASE", "")
.replace(f',{config["BACKENDS"]["LDAP"]["ROOT_DN"]}', "")
group_base = config["CANAILLE_LDAP"]["GROUP_BASE"].replace(
f',{config["CANAILLE_LDAP"]["ROOT_DN"]}', ""
)
models.Group.base = group_base or None
models.Group.rdn_attribute = config["BACKENDS"]["LDAP"].get(
"GROUP_RDN", models.Group.DEFAULT_RDN
)
object_class = config["BACKENDS"]["LDAP"].get(
"GROUP_CLASS", models.Group.DEFAULT_OBJECT_CLASS
)
models.Group.rdn_attribute = config["CANAILLE_LDAP"]["GROUP_RDN"]
object_class = config["CANAILLE_LDAP"]["GROUP_CLASS"]
models.Group.ldap_object_class = listify(object_class)

View file

@ -0,0 +1,58 @@
from pydantic import BaseModel
class LDAPSettings(BaseModel):
"""Settings related to the LDAP backend.
Belong in the ``CANAILLE_LDAP`` namespace.
"""
URI: str = "ldap://localhost"
"""The LDAP server URI."""
ROOT_DN: str = "dc=mydomain,dc=tld"
"""The LDAP root DN."""
BIND_DN: str = "cn=admin,dc=mydomain,dc=tld"
"""The LDAP bind DN."""
BIND_PW: str = "admin"
"""The LDAP bind password."""
TIMEOUT: float = 0.0
"""The LDAP connection timeout."""
USER_BASE: str
"""The LDAP node under which users will be looked for and saved.
For instance `ou=users,dc=mydomain,dc=tld`.
"""
USER_CLASS: str = "inetOrgPerson"
"""The object class to use for creating new users."""
USER_RDN: str = "uid"
"""The attribute to identify an object in the User DN."""
USER_FILTER: str = "member={user.id}"
"""Filter to match users on sign in.
For instance ``(|(uid={{ login }})(mail={{ login }}))``.
Jinja syntax is supported and a ``login`` variable is available,
containing the value passed in the login field.
"""
GROUP_BASE: str
"""The LDAP node under which groups will be looked for and saved.
For instance `"ou=groups,dc=mydomain,dc=tld"`.
"""
GROUP_CLASS: str = "groupOfNames"
"""The object class to use for creating new groups."""
GROUP_RDN: str = "cn"
"""The attribute to identify an object in the Group DN."""
GROUP_NAME_ATTRIBUTE: str = "cn"
"""The attribute to use to identify a group."""

View file

@ -12,10 +12,6 @@ from .ldapobject import LDAPObject
class User(canaille.core.models.User, LDAPObject):
DEFAULT_OBJECT_CLASS = "inetOrgPerson"
DEFAULT_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
DEFAULT_RDN = "cn"
attribute_map = {
"id": "dn",
"created": "createTimestamp",
@ -46,9 +42,7 @@ class User(canaille.core.models.User, LDAPObject):
@classmethod
def get_from_login(cls, login=None, **kwargs):
raw_filter = current_app.config["BACKENDS"]["LDAP"].get(
"USER_FILTER", User.DEFAULT_FILTER
)
raw_filter = current_app.config["CANAILLE_LDAP"]["USER_FILTER"]
filter = (
(
current_app.jinja_env.from_string(raw_filter).render(
@ -98,11 +92,11 @@ class User(canaille.core.models.User, LDAPObject):
return bool(self.password)
def check_password(self, password):
conn = ldap.initialize(current_app.config["BACKENDS"]["LDAP"]["URI"])
conn = ldap.initialize(current_app.config["CANAILLE_LDAP"]["URI"])
conn.set_option(
ldap.OPT_NETWORK_TIMEOUT,
current_app.config["BACKENDS"]["LDAP"].get("TIMEOUT"),
current_app.config["CANAILLE_LDAP"]["TIMEOUT"],
)
message = None
@ -180,7 +174,7 @@ class User(canaille.core.models.User, LDAPObject):
self.read = set()
self.write = set()
for access_group_name, details in current_app.config.get("ACL", {}).items():
for access_group_name, details in current_app.config["CANAILLE"]["ACL"].items():
filter_ = self.acl_filter_to_ldap_filter(details.get("FILTER"))
if not filter_ or (
self.id and conn.search_s(self.id, ldap.SCOPE_SUBTREE, filter_)
@ -191,11 +185,6 @@ class User(canaille.core.models.User, LDAPObject):
class Group(canaille.core.models.Group, LDAPObject):
DEFAULT_OBJECT_CLASS = "groupOfNames"
DEFAULT_RDN = "cn"
DEFAULT_NAME_ATTRIBUTE = "cn"
DEFAULT_USER_FILTER = "member={user.id}"
attribute_map = {
"id": "dn",
"created": "createTimestamp",
@ -211,9 +200,7 @@ class Group(canaille.core.models.Group, LDAPObject):
@property
def display_name(self):
attribute = current_app.config["BACKENDS"]["LDAP"].get(
"GROUP_NAME_ATTRIBUTE", Group.DEFAULT_NAME_ATTRIBUTE
)
attribute = current_app.config["CANAILLE_LDAP"]["GROUP_NAME_ATTRIBUTE"]
return getattr(self, attribute)[0]

View file

@ -240,7 +240,7 @@ class User(canaille.core.models.User, MemoryModel):
self.permissions = set()
self.read = set()
self.write = set()
for access_group_name, details in current_app.config.get("ACL", {}).items():
for access_group_name, details in current_app.config["CANAILLE"]["ACL"].items():
if self.match_filter(details.get("FILTER")):
self.permissions |= set(details.get("PERMISSIONS", []))
self.read |= set(details.get("READ", []))

View file

@ -57,7 +57,7 @@ class Model:
@classmethod
def get(cls, identifier=None, **kwargs):
"""Works like :meth:`~canaille.backends.models.query` but return only
one element or :const:`None` if no item is matching."""
one element or :py:data:`None` if no item is matching."""
raise NotImplementedError()
@property

View file

@ -21,7 +21,7 @@ class Backend(BaseBackend):
@classmethod
def install(cls, config): # pragma: no cover
engine = create_engine(
config["BACKENDS"]["SQL"]["SQL_DATABASE_URI"],
config["CANAILLE_SQL"]["DATABASE_URI"],
echo=False,
future=True,
)
@ -30,7 +30,7 @@ class Backend(BaseBackend):
def setup(self, init=False):
if not self.db_session:
self.db_session = db_session(
self.config["BACKENDS"]["SQL"]["SQL_DATABASE_URI"],
self.config["CANAILLE_SQL"]["DATABASE_URI"],
init=init,
)

View file

@ -0,0 +1,15 @@
from pydantic import BaseModel
class SQLSettings(BaseModel):
"""Settings related to the SQL backend.
Belong in the ``CANAILLE_SQL`` namespace.
"""
DATABASE_URI: str
"""The SQL server URI.
For example::
DATABASE_URI = "postgresql://user:password@localhost/database_name"
"""

View file

@ -179,7 +179,7 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
self.permissions = set()
self.read = set()
self.write = set()
for access_group_name, details in current_app.config.get("ACL", {}).items():
for access_group_name, details in current_app.config["CANAILLE"]["ACL"].items():
if self.match_filter(details.get("FILTER")):
self.permissions |= set(details.get("PERMISSIONS", []))
self.read |= set(details.get("READ", []))

View file

@ -1,16 +1,15 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
# The Flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
# NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
[CANAILLE]
# Your organization name.
# NAME = "Canaille"
# You can display a logo to be recognized on login screens
# LOGO = "/static/img/canaille-head.webp"
@ -75,12 +74,12 @@ SECRET_KEY = "change me before you go in production"
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
# [BACKENDS.SQL]
# [CANAILLE_SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
# DATABASE_URI = "postgresql://user:password@localhost/database"
# [BACKENDS.LDAP]
# [CANAILLE_LDAP]
# URI = "ldap://ldap"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
@ -113,7 +112,7 @@ SECRET_KEY = "change me before you go in production"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
[CANAILLE.ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
@ -146,7 +145,7 @@ SECRET_KEY = "change me before you go in production"
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +173,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,7 +187,7 @@ WRITE = [
"lock_date",
]
[OIDC]
[CANAILLE_OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
@ -204,15 +203,15 @@ WRITE = [
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
[CANAILLE_OIDC.JWT]
# PRIVATE_KEY and PUBLIC_KEY are the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# PRIVATE_KEY = "..."
# PUBLIC_KEY = "..."
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
@ -222,7 +221,7 @@ WRITE = [
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
[CANAILLE_OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
@ -241,7 +240,7 @@ WRITE = [
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
[CANAILLE.SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false

View file

@ -0,0 +1,310 @@
from enum import Enum
from typing import Dict
from typing import List
from typing import Optional
from pydantic import BaseModel
from pydantic import ValidationInfo
from pydantic import field_validator
class SMTPSettings(BaseModel):
"""The SMTP configuration. Belong in the ``CANAILLE.SMTP`` namespace. If
unset, mail related features will be disabled, such as mail verification or
password recovery emails.
By default, Canaille will try to send mails from localhost without
authentication.
"""
HOST: Optional[str] = "localhost"
"""The SMTP host."""
PORT: Optional[int] = 25
"""The SMTP port."""
TLS: Optional[bool] = False
"""Wether to use TLS to connect to the SMTP server."""
SSL: Optional[bool] = False
"""Wether to use SSL to connect to the SMTP server."""
LOGIN: Optional[str] = None
"""The SMTP login."""
PASSWORD: Optional[str] = None
"""The SMTP password."""
FROM_ADDR: Optional[str] = None
"""The sender for Canaille mails.
Some mail provider might require a valid sender address.
"""
class Permission(str, Enum):
"""The permissions that can be assigned to users."""
EDIT_SELF = "edit_self"
"""Allows users to edit their own profile."""
USE_OIDC = "use_oidc"
"""Allows OpenID Connect authentication."""
MANAGE_OIDC = "manage_oidc"
"""Allows OpenID Connect client managements."""
MANAGE_USERS = "manage_users"
"""Allows other users management."""
MANAGE_GROUPS = "manage_groups"
"""Allows group edition and creation."""
DELETE_ACCOUNT = "delete_account"
"""Allows users to delete their account.
If used with :enum:member:`~canaille.core.configuration.Permission.MANAGE_USERS`, users can delete any account.
"""
IMPERSONATE_USERS = "impersonate_users"
"""Allows users to take the identity of another user."""
class ACLSettings(BaseModel):
"""Access Control List settings. Belong in the ``CANAILLE.ACL`` namespace.
You can define access controls that define what users can do on canaille
An access control consists in a :attr:`FILTER` to match users, a list of :attr:`PERMISSIONS`
matched users will be able to perform, and fields users will be able
to :attr:`READ` and :attr:`WRITE`. Users matching several filters will cumulate permissions.
"""
PERMISSIONS: List[Permission] = [Permission.EDIT_SELF, Permission.USE_OIDC]
"""A list of :class:`Permission` users in the access control will be able
to manage. For example::
PERMISSIONS = [
"manage_users",
"manage_groups",
"manage_oidc",
"delete_account",
"impersonate_users",
]"""
READ: List[str] = [
"user_name",
"groups",
"lock_date",
]
"""A list of :class:`~canaille.core.models.User` attributes that users in
the ACL will be able to read."""
WRITE: List[str] = [
"photo",
"given_name",
"family_name",
"display_name",
"password",
"phone_numbers",
"emails",
"profile_url",
"formatted_address",
"street",
"postal_code",
"locality",
"region",
"preferred_language",
"employee_number",
"department",
"title",
"organization",
]
"""A list of :class:`~canaille.core.models.User` attributes that users in
the ACL will be able to edit."""
@field_validator("READ")
def validate_read_values(
cls,
read: List[str],
info: ValidationInfo,
) -> List[str]:
from canaille.core.models import User
assert all(field in User.__annotations__ for field in read)
return read
@field_validator("WRITE")
def validate_write_values(
cls,
write: List[str],
info: ValidationInfo,
) -> List[str]:
from canaille.core.models import User
assert all(field in User.__annotations__ for field in write)
return write
FILTER: Optional[Dict[str, str] | List[Dict[str, str]]] = None
""":attr:`FILTER` can be:
- :py:data:`None`, in which case all the users will match this access control
- a mapping where keys are user attributes name and the values those user
attribute values. All the values must be matched for the user to be part
of the access control.
- a list of those mappings. If a user values match at least one mapping,
then the user will be part of the access control
Here are some examples::
FILTER = {user_name = 'admin'}
FILTER = [
{groups = 'admins},
{groups = 'moderators'},
]
"""
class CoreSettings(BaseModel):
"""The settings from the ``CANAILLE`` namespace.
Those are all the configuration parameters that controls the
behavior of Canaille.
"""
NAME: str = "Canaille"
"""Your organization name.
Used for display purpose.
"""
LOGO: Optional[str] = None
"""The logo of your organization, this is useful to make your organization
recognizable on login screens."""
FAVICON: Optional[str] = None
"""You favicon.
If unset and :attr:`LOGO` is set, then the logo will be used.
"""
THEME: str = "default"
"""The name of a theme in the 'theme' directory, or a path to a theme.
Defaults to ``default``. Theming is done with `flask-themer <https://github.com/tktech/flask-themer>`_.
"""
LANGUAGE: Optional[str] = None
"""If a language code is set, it will be used for every user.
If unset, the language is guessed according to the users browser.
"""
TIMEZONE: Optional[str] = None
"""The timezone in which datetimes will be displayed to the users (e.g.
``CEST``).
If unset, the server timezone will be used.
"""
SENTRY_DSN: Optional[str] = None
"""A `Sentry <https://sentry.io>`_ DSN to collect the exceptions.
This is useful for tracking errors in test and production environments.
"""
JAVASCRIPT: bool = True
"""Enables Javascript to smooth the user experience."""
HTMX: bool = True
"""Accelerates webpages loading with asynchroneous requests."""
EMAIL_CONFIRMATION: bool = True
"""If :py:data:`True`, users will need to click on a confirmation link sent
by email when they want to add a new email.
By default, this is true
if ``SMTP`` is configured, else this is false. If explicitely set to true
and ``SMTP`` is disabled, the email field will be read-only.
"""
ENABLE_REGISTRATION: bool = False
"""If :py:data:`True`, then users can freely create an account at this
instance.
If email verification is available, users must confirm their email
before the account is created.
"""
HIDE_INVALID_LOGINS: bool = True
"""If :py:data:`True`, when users try to sign in with an invalid login, a
message is shown indicating that the password is wrong, but does not give a
clue wether the login exists or not.
If :py:data:`False`,
when a user tries to sign in with an invalid login, a message is shown
indicating that the login does not exist.
"""
ENABLE_PASSWORD_RECOVERY: bool = True
"""If :py:data:`False`, then users cannot ask for a password recovery link
by email."""
INVITATION_EXPIRATION: int = 172800
"""The validity duration of registration invitations, in seconds.
Defaults to 2 days.
"""
LOGGING: Optional[str | Dict] = None
"""Configures the logging output using the python logging configuration format:
- if :py:data:`None`, everything is logged in the standard output
the log level is :py:data:`~logging.DEBUG` if :attr:`RootSettings.DEBUG` is :py:data:`True`, else this is :py:data:`~logging.INFO`
- if this is a dictionnary, it is passed to :func:`logging.config.dictConfig`:
- if this is a string, it is expected to be a file path that will be passed
to :func:`logging.config.fileConfig`
For example::
[CANAILLE]
LOGGING = {
version = 1,
formatters = {
default = {
format = "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
},
},
handlers = {
canaille = {
class = "logging.handlers.WatchedFileHandler",
filename = /foo/bar/canaille.log,
formatter = "default",
}
},
root = {
level = "INFO",
handlers = ["canaille"],
},
}
"""
SMTP: Optional[SMTPSettings] = None
"""The settings related to SMTP and mail configuration.
If unset, mail-related features like password recovery won't be
enabled.
"""
ACL: Optional[Dict[str, ACLSettings]] = {"DEFAULT": ACLSettings()}
"""Mapping of permission groups. See :class:`ACLSettings` for more details.
The ACL name can be freely choosed. For example::
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = ["user_name", "groups"]
WRITE = ["given_name", "family_name"]
[CANAILLE.ACL.ADMIN]
WRITE = ["user_name", "groups"]
"""

View file

@ -67,7 +67,7 @@ def index():
if user.can_edit_self or user.can_manage_users:
return redirect(url_for("core.account.profile_edition", edited_user=user))
if "OIDC" in current_app.config and user.can_use_oidc:
if "CANAILLE_OIDC" in current_app.config and user.can_use_oidc:
return redirect(url_for("oidc.consents.consents"))
return redirect(url_for("core.account.about"))
@ -75,10 +75,10 @@ def index():
@bp.route("/join", methods=("GET", "POST"))
def join():
if not current_app.config.get("ENABLE_REGISTRATION", False):
if not current_app.config["CANAILLE"]["ENABLE_REGISTRATION"]:
abort(404)
if not current_app.config.get("EMAIL_CONFIRMATION", True):
if not current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"]:
return redirect(url_for(".registration"))
if current_user():
@ -163,13 +163,10 @@ class VerificationPayload:
return datetime.datetime.fromisoformat(self.creation_date_isoformat)
def has_expired(self):
DEFAULT_INVITATION_DURATION = 2 * 24 * 60 * 60
return datetime.datetime.now(
datetime.timezone.utc
) - self.creation_date > datetime.timedelta(
seconds=current_app.config.get(
"INVITATION_EXPIRATION", DEFAULT_INVITATION_DURATION
)
seconds=current_app.config["CANAILLE"]["INVITATION_EXPIRATION"]
)
def b64(self):
@ -235,9 +232,10 @@ def user_invitation(user):
def registration(data=None, hash=None):
if not data:
payload = None
if not current_app.config.get(
"ENABLE_REGISTRATION", False
) or current_app.config.get("EMAIL_CONFIRMATION", True):
if (
not current_app.config["CANAILLE"]["ENABLE_REGISTRATION"]
or current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"]
):
abort(403)
else:
@ -285,9 +283,9 @@ def registration(data=None, hash=None):
"groups": [models.Group.get(id=group_id) for group_id in payload.groups],
}
has_smtp = "SMTP" in current_app.config
emails_readonly = current_app.config.get("EMAIL_CONFIRMATION") is True or (
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
has_smtp = "SMTP" in current_app.config["CANAILLE"]
emails_readonly = current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is True or (
current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None and has_smtp
)
readable_fields, writable_fields = default_fields()
@ -581,9 +579,11 @@ def profile_edition(user, edited_user):
abort(404)
menuitem = "profile" if edited_user.id == user.id else "users"
has_smtp = "SMTP" in current_app.config
has_email_confirmation = current_app.config.get("EMAIL_CONFIRMATION") is True or (
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
has_smtp = "SMTP" in current_app.config["CANAILLE"]
has_email_confirmation = current_app.config["CANAILLE"][
"EMAIL_CONFIRMATION"
] is True or (
current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None and has_smtp
)
emails_readonly = has_email_confirmation and not user.can_manage_users

View file

@ -52,11 +52,11 @@ def test_html(user):
base_url = url_for("core.account.index", _external=True)
return render_template(
"mails/test.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
logo=current_app.config.get("LOGO"),
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("Test email from {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille"),
website_name=current_app.config["CANAILLE"]["NAME"],
),
)
@ -67,7 +67,7 @@ def test_txt(user):
base_url = url_for("core.account.index", _external=True)
return render_template(
"mails/test.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=current_app.config.get("SERVER_NAME", base_url),
)
@ -81,19 +81,19 @@ def password_init_html(user):
user=user,
hash=build_hash(user.identifier, user.preferred_email, user.password),
title=_("Password initialization on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
_external=True,
)
return render_template(
"mails/firstlogin.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
reset_url=reset_url,
logo=current_app.config.get("LOGO"),
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("Password initialization on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
)
@ -111,7 +111,7 @@ def password_init_txt(user):
return render_template(
"mails/firstlogin.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=current_app.config.get("SERVER_NAME", base_url),
reset_url=reset_url,
)
@ -126,19 +126,19 @@ def password_reset_html(user):
user=user,
hash=build_hash(user.identifier, user.preferred_email, user.password),
title=_("Password reset on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
_external=True,
)
return render_template(
"mails/reset.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
reset_url=reset_url,
logo=current_app.config.get("LOGO"),
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("Password reset on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
)
@ -156,7 +156,7 @@ def password_reset_txt(user):
return render_template(
"mails/reset.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=current_app.config.get("SERVER_NAME", base_url),
reset_url=reset_url,
)
@ -175,12 +175,12 @@ def invitation_html(user, identifier, email):
return render_template(
"mails/invitation.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
logo=current_app.config.get("LOGO"),
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("Invitation on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
)
@ -198,7 +198,7 @@ def invitation_txt(user, identifier, email):
return render_template(
"mails/invitation.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
)
@ -217,12 +217,12 @@ def email_confirmation_html(user, identifier, email):
return render_template(
"mails/email-confirmation.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
confirmation_url=email_confirmation_url,
logo=current_app.config.get("LOGO"),
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("Email confirmation on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
)
@ -240,7 +240,7 @@ def email_confirmation_txt(user, identifier, email):
return render_template(
"mails/email-confirmation.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
confirmation_url=email_confirmation_url,
)
@ -259,12 +259,12 @@ def registration_html(user, email):
return render_template(
"mails/registration.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
logo=current_app.config.get("LOGO"),
logo=current_app.config["CANAILLE"]["LOGO"],
title=_("Email confirmation on {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
),
)
@ -282,7 +282,7 @@ def registration_txt(user, email):
return render_template(
"mails/registration.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
)

View file

@ -142,7 +142,7 @@ def firstlogin(user):
@bp.route("/reset", methods=["GET", "POST"])
@smtp_needed()
def forgotten():
if not current_app.config.get("ENABLE_PASSWORD_RECOVERY", True):
if not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]:
abort(404)
form = ForgottenPasswordForm(request.form)
@ -158,7 +158,7 @@ def forgotten():
"A password reset link has been sent at your email address. "
"You should receive it within a few minutes."
)
if current_app.config.get("HIDE_INVALID_LOGINS", True) and (
if current_app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] and (
not user or not user.can_edit_self
):
flash(success_message, "success")
@ -191,7 +191,7 @@ def forgotten():
@bp.route("/reset/<user:user>/<hash>", methods=["GET", "POST"])
def reset(user, hash):
if not current_app.config.get("ENABLE_PASSWORD_RECOVERY", True):
if not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]:
abort(404)
form = PasswordResetForm(request.form)

View file

@ -47,9 +47,9 @@ def unique_group(form, field):
def existing_login(form, field):
if not current_app.config.get(
"HIDE_INVALID_LOGINS", True
) and not models.User.get_from_login(field.data):
if not current_app.config["CANAILLE"][
"HIDE_INVALID_LOGINS"
] and not models.User.get_from_login(field.data):
raise wtforms.ValidationError(
_("The login '{login}' does not exist").format(login=field.data)
)
@ -365,7 +365,7 @@ class JoinForm(Form):
)
def validate_email(form, field):
if not current_app.config.get("HIDE_INVALID_LOGINS", True):
if not current_app.config["CANAILLE"]["HIDE_INVALID_LOGINS"]:
unique_email(form, field)

View file

@ -13,16 +13,16 @@ def send_test_mail(email):
logo_cid, logo_filename, logo_raw = logo()
subject = _("Test email from {website_name}").format(
website_name=current_app.config.get("NAME", "Canaille")
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/test.txt",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
)
html_body = render_template(
"mails/test.html",
site_name=current_app.config.get("NAME", "Canaille"),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
title=subject,
@ -52,17 +52,17 @@ def send_password_reset_mail(user, mail):
logo_cid, logo_filename, logo_raw = logo()
subject = _("Password reset on {website_name}").format(
website_name=current_app.config.get("NAME", base_url)
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/reset.txt",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
reset_url=reset_url,
)
html_body = render_template(
"mails/reset.html",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
reset_url=reset_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
@ -93,17 +93,17 @@ def send_password_initialization_mail(user, email):
logo_cid, logo_filename, logo_raw = logo()
subject = _("Password initialization on {website_name}").format(
website_name=current_app.config.get("NAME", base_url)
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/firstlogin.txt",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
reset_url=reset_url,
)
html_body = render_template(
"mails/firstlogin.html",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
reset_url=reset_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
@ -124,17 +124,17 @@ def send_invitation_mail(email, registration_url):
logo_cid, logo_filename, logo_raw = logo()
subject = _("You have been invited to create an account on {website_name}").format(
website_name=current_app.config.get("NAME", base_url)
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/invitation.txt",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
)
html_body = render_template(
"mails/invitation.html",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
@ -155,17 +155,17 @@ def send_confirmation_email(email, confirmation_url):
logo_cid, logo_filename, logo_raw = logo()
subject = _("Confirm your address email on {website_name}").format(
website_name=current_app.config.get("NAME", base_url)
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/email-confirmation.txt",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
confirmation_url=confirmation_url,
)
html_body = render_template(
"mails/email-confirmation.html",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
confirmation_url=confirmation_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
@ -186,17 +186,17 @@ def send_registration_mail(email, registration_url):
logo_cid, logo_filename, logo_raw = logo()
subject = _("Continue your registration on {website_name}").format(
website_name=current_app.config.get("NAME", registration_url)
website_name=current_app.config["CANAILLE"]["NAME"]
)
text_body = render_template(
"mails/registration.txt",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
)
html_body = render_template(
"mails/registration.html",
site_name=current_app.config.get("NAME", base_url),
site_name=current_app.config["CANAILLE"]["NAME"],
site_url=base_url,
registration_url=registration_url,
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,

View file

@ -0,0 +1,106 @@
from typing import List
from typing import Optional
from pydantic import BaseModel
class JWTMappingSettings(BaseModel):
"""Mapping between the user model and the JWT fields.
Fiels are evaluated with jinja.
A ``user`` var is available.
"""
SUB: Optional[str] = "{{ user.user_name }}"
NAME: Optional[str] = (
"{% if user.formatted_name %}{{ user.formatted_name }}{% endif %}"
)
PHONE_NUMBER: Optional[str] = (
"{% if user.phone_numbers %}{{ user.phone_numbers[0] }}{% endif %}"
)
EMAIL: Optional[str] = (
"{% if user.preferred_email %}{{ user.preferred_email }}{% endif %}"
)
GIVEN_NAME: Optional[str] = (
"{% if user.given_name %}{{ user.given_name }}{% endif %}"
)
FAMILY_NAME: Optional[str] = (
"{% if user.family_name %}{{ user.family_name }}{% endif %}"
)
PREFERRED_USERNAME: Optional[str] = (
"{% if user.display_name %}{{ user.display_name }}{% endif %}"
)
LOCALE: Optional[str] = (
"{% if user.preferred_language %}{{ user.preferred_language }}{% endif %}"
)
ADDRESS: Optional[str] = (
"{% if user.formatted_address %}{{ user.formatted_address }}{% endif %}"
)
PICTURE: Optional[str] = (
"{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
)
WEBSITE: Optional[str] = (
"{% if user.profile_url %}{{ user.profile_url }}{% endif %}"
)
class JWTSettings(BaseModel):
"""JSON Web Token settings. Belong in the ``CANAILLE_OIDC.JWT`` namespace.
You can generate a RSA keypair with::
openssl genrsa -out private.pem 4096
openssl rsa -in private.pem -pubout -outform PEM -out public.pem
"""
PRIVATE_KEY: Optional[str] = None
"""The private key.
If :py:data:`None` and debug mode is enabled, then an in-memory key will be used.
"""
PUBLIC_KEY: Optional[str] = None
"""The public key.
If :py:data:`None` and debug mode is enabled, then an in-memory key will be used.
"""
ISS: Optional[str] = None
"""The URI of the identity provider."""
KTY: str = "RSA"
"""The key type."""
ALG: str = "RS256"
"""The key algorithm."""
EXP: int = 3600
"""The time the JWT will be valid, in seconds."""
MAPPING: Optional[JWTMappingSettings] = JWTMappingSettings()
class OIDCSettings(BaseModel):
"""OpenID Connect settings.
Belong in the ``CANAILLE_OIDC`` namespace.
"""
DYNAMIC_CLIENT_REGISTRATION_OPEN: bool = False
"""Wether a token is needed for the RFC7591 dynamical client registration.
If :py:data:`True`, no token is needed to register a client.
If :py:data:`False`, dynamical client registration needs a token defined in :attr:`DYNAMIC_CLIENT_REGISTRATION_TOKENS`.
"""
DYNAMIC_CLIENT_REGISTRATION_TOKENS: Optional[List[str]] = None
"""A list of tokens that can be used for dynamic client registration."""
REQUIRE_NONCE: bool = True
"""Force the nonce exchange during the authentication flows.
This adds security but may not be supported by all clients.
"""
JWT: Optional[JWTSettings] = None
"""JSON Web Token settings."""

View file

@ -276,7 +276,8 @@ def end_session():
if data.get("id_token_hint"):
try:
id_token = jwt.decode(
data["id_token_hint"], current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"]
data["id_token_hint"],
current_app.config["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"],
)
except JoseError as exc:
return jsonify(

View file

@ -20,14 +20,15 @@ def generate_keypair():
def install(config, debug=False):
if (
not debug
or not config.get("OIDC", {}).get("JWT")
or not config.get("CANAILLE_OIDC")
or not config["CANAILLE_OIDC"].get("JWT")
or (
config["OIDC"]["JWT"].get("PUBLIC_KEY")
and config["OIDC"]["JWT"].get("PRIVATE_KEY")
config["CANAILLE_OIDC"]["JWT"].get("PUBLIC_KEY")
and config["CANAILLE_OIDC"]["JWT"].get("PRIVATE_KEY")
)
):
return
private_key, public_key = generate_keypair()
config["OIDC"]["JWT"]["PUBLIC_KEY"] = public_key.decode()
config["OIDC"]["JWT"]["PRIVATE_KEY"] = private_key.decode()
config["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"] = public_key.decode()
config["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"] = private_key.decode()

View file

@ -35,23 +35,7 @@ from werkzeug.security import gen_salt
from canaille.app import models
DEFAULT_JWT_KTY = "RSA"
DEFAULT_JWT_ALG = "RS256"
DEFAULT_JWT_EXP = 3600
AUTHORIZATION_CODE_LIFETIME = 84400
DEFAULT_JWT_MAPPING = {
"SUB": "{{ user.user_name }}",
"NAME": "{% if user.formatted_name %}{{ user.formatted_name }}{% endif %}",
"PHONE_NUMBER": "{% if user.phone_numbers %}{{ user.phone_numbers[0] }}{% endif %}",
"EMAIL": "{% if user.preferred_email %}{{ user.preferred_email }}{% endif %}",
"GIVEN_NAME": "{% if user.given_name %}{{ user.given_name }}{% endif %}",
"FAMILY_NAME": "{% if user.family_name %}{{ user.family_name }}{% endif %}",
"PREFERRED_USERNAME": "{% if user.display_name %}{{ user.display_name }}{% endif %}",
"LOCALE": "{% if user.preferred_language %}{{ user.preferred_language }}{% endif %}",
"ADDRESS": "{% if user.formatted_address %}{{ user.formatted_address }}{% endif %}",
"PICTURE": "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}",
"WEBSITE": "{% if user.profile_url %}{{ user.profile_url }}{% endif %}",
}
def oauth_authorization_server():
@ -121,7 +105,7 @@ def openid_configuration():
"subject_types_supported": ["pairwise", "public"],
"id_token_signing_alg_values_supported": ["RS256", "ES256", "HS256"],
"prompt_values_supported": ["none"]
+ (["create"] if current_app.config.get("ENABLE_REGISTRATION") else []),
+ (["create"] if current_app.config["CANAILLE"]["ENABLE_REGISTRATION"] else []),
}
@ -132,8 +116,8 @@ def exists_nonce(nonce, req):
def get_issuer():
if current_app.config["OIDC"]["JWT"].get("ISS"):
return current_app.config["OIDC"]["JWT"].get("ISS")
if current_app.config["CANAILLE_OIDC"]["JWT"]["ISS"]:
return current_app.config["CANAILLE_OIDC"]["JWT"]["ISS"]
if current_app.config.get("SERVER_NAME"):
return current_app.config.get("SERVER_NAME")
@ -143,18 +127,18 @@ def get_issuer():
def get_jwt_config(grant=None):
return {
"key": current_app.config["OIDC"]["JWT"]["PRIVATE_KEY"],
"alg": current_app.config["OIDC"]["JWT"].get("ALG", DEFAULT_JWT_ALG),
"key": current_app.config["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"],
"alg": current_app.config["CANAILLE_OIDC"]["JWT"]["ALG"],
"iss": get_issuer(),
"exp": current_app.config["OIDC"]["JWT"].get("EXP", DEFAULT_JWT_EXP),
"exp": current_app.config["CANAILLE_OIDC"]["JWT"]["EXP"],
}
def get_jwks():
kty = current_app.config["OIDC"]["JWT"].get("KTY", DEFAULT_JWT_KTY)
alg = current_app.config["OIDC"]["JWT"].get("ALG", DEFAULT_JWT_ALG)
kty = current_app.config["CANAILLE_OIDC"]["JWT"]["KTY"]
alg = current_app.config["CANAILLE_OIDC"]["JWT"]["ALG"]
jwk = JsonWebKey.import_key(
current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"], {"kty": kty}
current_app.config["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"], {"kty": kty}
)
return {
"keys": [
@ -204,8 +188,7 @@ def generate_user_info(user, scope):
def generate_user_claims(user, claims, jwt_mapping_config=None):
jwt_mapping_config = {
**DEFAULT_JWT_MAPPING,
**(current_app.config["OIDC"]["JWT"].get("MAPPING") or {}),
**(current_app.config["CANAILLE_OIDC"]["JWT"]["MAPPING"]),
**(jwt_mapping_config or {}),
}
@ -420,9 +403,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
class ClientManagementMixin:
def authenticate_token(self, request):
if current_app.config.get("OIDC", {}).get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN", False
):
if current_app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"]:
return True
auth_header = request.headers.get("Authorization")
@ -431,7 +412,7 @@ class ClientManagementMixin:
bearer_token = auth_header.split()[1]
if bearer_token not in (
current_app.config.get("OIDC", {}).get("DYNAMIC_CLIENT_REGISTRATION_TOKENS")
current_app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"]
or []
):
return None
@ -445,7 +426,7 @@ class ClientManagementMixin:
def resolve_public_key(self, request):
# At the moment the only keypair accepted in software statement
# is the one used to isues JWTs. This might change somedays.
return current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"]
return current_app.config["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
def client_convert_data(self, **kwargs):
if "client_id_issued_at" in kwargs:
@ -533,6 +514,9 @@ def generate_access_token(client, grant_type, user, scope):
def setup_oauth(app):
# hacky, but needed for tests as somehow the same 'authorization' object is used
# between tests
authorization.__init__()
authorization.init_app(app, query_client=query_client, save_token=save_token)
authorization.register_grant(PasswordGrant)
@ -543,9 +527,7 @@ def setup_oauth(app):
authorization.register_grant(
AuthorizationCodeGrant,
[
OpenIDCode(
require_nonce=app.config.get("OIDC", {}).get("REQUIRE_NONCE", True)
),
OpenIDCode(require_nonce=app.config["CANAILLE_OIDC"]["REQUIRE_NONCE"]),
CodeChallenge(required=True),
],
)

View file

@ -1,152 +1,23 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
[CANAILLE]
NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
LOGO = "/static/img/canaille-head.webp"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.webp"
# The name of a theme in the 'theme' directory, or a path path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
EMAIL_CONFIRMATION = false
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
# LOGGING configures the logging output:
# - if unset, everything is logged in the standard output
# the log level is debug if DEBUG is True, else this is INFO
# - if this is a dictionnary, it is passed to the python dictConfig method:
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
# [BACKENDS.SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
[BACKENDS.LDAP]
[CANAILLE_LDAP]
URI = "ldap://ldap:389"
ROOT_DN = "dc=mydomain,dc=tld"
BIND_DN = "cn=admin,dc=mydomain,dc=tld"
BIND_PW = "admin"
TIMEOUT = 10
# Where to search for users?
USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +45,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,75 +59,13 @@ WRITE = [
"lock_date",
]
[ACL.HALF_ADMIN]
[CANAILLE.ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
[CANAILLE_OIDC]
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name }}"
# NAME = "{{ user.formatted_name }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name }}"
# FAMILY_NAME = "{{ user.family_name }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
# [REGISTRATION]
# GROUPS=[]
# CAN_EDIT_USERNAME = false

View file

@ -1,152 +1,13 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
[CANAILLE]
NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
LOGO = "/static/img/canaille-head.webp"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.webp"
# The name of a theme in the 'theme' directory, or a path path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
EMAIL_CONFIRMATION = false
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
# LOGGING configures the logging output:
# - if unset, everything is logged in the standard output
# the log level is debug if DEBUG is True, else this is INFO
# - if this is a dictionnary, it is passed to the python dictConfig method:
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
# [BACKENDS.SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
# [BACKENDS.LDAP]
# URI = "ldap://ldap:389"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
# BIND_PW = "admin"
# TIMEOUT = 10
# Where to search for users?
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +35,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,75 +49,13 @@ WRITE = [
"lock_date",
]
[ACL.HALF_ADMIN]
[CANAILLE.ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
[CANAILLE_OIDC]
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name }}"
# NAME = "{{ user.formatted_name }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name }}"
# FAMILY_NAME = "{{ user.family_name }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
# [REGISTRATION]
# GROUPS=[]
# CAN_EDIT_USERNAME = false

View file

@ -1,152 +1,16 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
[CANAILLE]
NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
LOGO = "/static/img/canaille-head.webp"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.webp"
# The name of a theme in the 'theme' directory, or a path path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
EMAIL_CONFIRMATION = false
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
[CANAILLE_SQL]
DATABASE_URI = "sqlite:///demo.sqlite"
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
# LOGGING configures the logging output:
# - if unset, everything is logged in the standard output
# the log level is debug if DEBUG is True, else this is INFO
# - if this is a dictionnary, it is passed to the python dictConfig method:
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
[BACKENDS.SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
SQL_DATABASE_URI = "sqlite:///demo.sqlite"
# [BACKENDS.LDAP]
# URI = "ldap://ldap:389"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
# BIND_PW = "admin"
# TIMEOUT = 10
# Where to search for users?
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +38,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,75 +52,13 @@ WRITE = [
"lock_date",
]
[ACL.HALF_ADMIN]
[CANAILLE.ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
[CANAILLE_OIDC]
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name }}"
# NAME = "{{ user.formatted_name }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name }}"
# FAMILY_NAME = "{{ user.family_name }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
# [REGISTRATION]
# GROUPS=[]
# CAN_EDIT_USERNAME = false

View file

@ -1,152 +1,21 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
# NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
[CANAILLE]
LOGO = "/static/img/canaille-head.webp"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.webp"
# The name of a theme in the 'theme' directory, or a path path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
EMAIL_CONFIRMATION = false
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
# LOGGING configures the logging output:
# - if unset, everything is logged in the standard output
# the log level is debug if DEBUG is True, else this is INFO
# - if this is a dictionnary, it is passed to the python dictConfig method:
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
# [BACKENDS.SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
[BACKENDS.LDAP]
[CANAILLE_LDAP]
URI = "ldap://127.0.0.1:5389"
ROOT_DN = "dc=mydomain,dc=tld"
BIND_DN = "cn=admin,dc=mydomain,dc=tld"
BIND_PW = "admin"
TIMEOUT = 10
# Where to search for users?
USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +43,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,73 +57,13 @@ WRITE = [
"lock_date",
]
[ACL.HALF_ADMIN]
[CANAILLE.ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
# The jwt configuration. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
[CANAILLE_OIDC]
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name }}"
# NAME = "{{ user.formatted_name }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name }}"
# FAMILY_NAME = "{{ user.family_name }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"

View file

@ -1,152 +1,12 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
# NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
[CANAILLE]
LOGO = "/static/img/canaille-head.webp"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.webp"
# The name of a theme in the 'theme' directory, or a path path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
EMAIL_CONFIRMATION = false
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
# LOGGING configures the logging output:
# - if unset, everything is logged in the standard output
# the log level is debug if DEBUG is True, else this is INFO
# - if this is a dictionnary, it is passed to the python dictConfig method:
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
# [BACKENDS.SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
# [BACKENDS.LDAP]
# URI = "ldap://localhost"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
# BIND_PW = "admin"
# TIMEOUT = 10
# Where to search for users?
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +34,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,73 +48,13 @@ WRITE = [
"lock_date",
]
[ACL.HALF_ADMIN]
[CANAILLE.ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
# The jwt configuration. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
[CANAILLE_OIDC]
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name }}"
# NAME = "{{ user.formatted_name }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name }}"
# FAMILY_NAME = "{{ user.family_name }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"

View file

@ -1,152 +1,15 @@
# All the Flask configuration values can be used:
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
# The flask secret key for cookies. You MUST change this.
SECRET_KEY = "change me before you go in production"
# Your organization name.
# NAME = "Canaille"
# The interface on which canaille will be served
# SERVER_NAME = "auth.mydomain.tld"
# PREFERRED_URL_SCHEME = "https"
# You can display a logo to be recognized on login screens
[CANAILLE]
LOGO = "/static/img/canaille-head.webp"
# Your favicon. If unset the LOGO will be used.
FAVICON = "/static/img/canaille-c.webp"
# The name of a theme in the 'theme' directory, or a path path
# to a theme. Defaults to 'default'. Theming is done with
# https://github.com/tktech/flask-themer
# THEME = "default"
# If unset, language is detected
# LANGUAGE = "en"
# The timezone in which datetimes will be displayed to the users.
# If unset, the server timezone will be used.
# TIMEZONE = UTC
# If you have a sentry instance, you can set its dsn here:
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
# Enables javascript to smooth the user experience
# JAVASCRIPT = true
# Accelerates webpages with async requests
# HTMX = true
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
# confirmation link sent by email when they want to add a new email.
# By default, this is true if SMTP is configured, else this is false.
# If explicitely set to true and SMTP is disabled, the email field
# will be read-only.
EMAIL_CONFIRMATION = false
# If ENABLE_REGISTRATION is true, then users can freely create an account
# at this instance. If email verification is available, users must confirm
# their email before the account is created.
ENABLE_REGISTRATION = true
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
# tries to sign in with an invalid login, a message is shown indicating
# that the password is wrong, but does not give a clue wether the login
# exists or not.
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
# an invalid login, a message is shown indicating that the login does not
# exist.
# HIDE_INVALID_LOGINS = true
[CANAILLE_SQL]
DATABASE_URI = "sqlite:///demo.sqlite"
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
# recovery link by email. This option is true by default.
# ENABLE_PASSWORD_RECOVERY = true
# The validity duration of registration invitations, in seconds.
# Defaults to 2 days
# INVITATION_EXPIRATION = 172800
# LOGGING configures the logging output:
# - if unset, everything is logged in the standard output
# the log level is debug if DEBUG is True, else this is INFO
# - if this is a dictionnary, it is passed to the python dictConfig method:
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
# - if this is a string, it is passed to the python fileConfig method
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
[BACKENDS.SQL]
# The SQL database connection string
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
SQL_DATABASE_URI = "sqlite:///demo.sqlite"
# [BACKENDS.LDAP]
# URI = "ldap://localhost"
# ROOT_DN = "dc=mydomain,dc=tld"
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
# BIND_PW = "admin"
# TIMEOUT = 10
# Where to search for users?
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
# The object class to use for creating new users
# USER_CLASS = "inetOrgPerson"
# The attribute to identify an object in the User dn.
# USER_RDN = "uid"
# Filter to match users on sign in. Jinja syntax is supported
# and a `login` variable is available containing the value
# passed in the login field.
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
# Where to search for groups?
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
# The object class to use for creating new groups
# GROUP_CLASS = "groupOfNames"
# The attribute to identify an object in the User dn.
# GROUP_RDN = "cn"
# The attribute to use to identify a group
# GROUP_NAME_ATTRIBUTE = "cn"
[ACL]
# You can define access controls that define what users can do on canaille
# An access control consists in a FILTER to match users, a list of PERMISSIONS
# matched users will be able to perform, and fields users will be able
# to READ and WRITE. Users matching several filters will cumulate permissions.
#
# 'FILTER' parameter can be:
# - absent, in which case all the users will match this access control
# - a mapping where keys are user attributes name and the values those user
# attribute values. All the values must be matched for the user to be part
# of the access control.
# - a list of those mappings. If a user values match at least one mapping,
# then the user will be part of the access control
#
# Here are some examples
# FILTER = {user_name = 'admin'}
# FILTER =
# - {groups = 'admins'}
# - {groups = 'moderators'}
#
# The 'PERMISSIONS' parameter that is an list of items the users in the access
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
# - "edit_self" to allow users to edit their own profile
# - "use_oidc" to allow OpenID Connect authentication
# - "manage_oidc" to allow OpenID Connect client managements
# - "manage_users" to allow other users management
# - "manage_groups" to allow group edition and creation
# - "delete_account" allows a user to delete his own account. If used with
# manage_users, the user can delete any account
# - "impersonate_users" to allow a user to take the identity of another user
#
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
# object that users will be able to read and/or write.
[ACL.DEFAULT]
[CANAILLE.ACL.DEFAULT]
PERMISSIONS = ["edit_self", "use_oidc"]
READ = [
"user_name",
@ -174,7 +37,7 @@ WRITE = [
"organization",
]
[ACL.ADMIN]
[CANAILLE.ACL.ADMIN]
FILTER = {groups = "admins"}
PERMISSIONS = [
"manage_users",
@ -188,73 +51,13 @@ WRITE = [
"lock_date",
]
[ACL.HALF_ADMIN]
[CANAILLE.ACL.HALF_ADMIN]
FILTER = {groups = "moderators"}
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
WRITE = ["groups"]
# The jwt configuration. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
[OIDC]
# Wether a token is needed for the RFC7591 dynamical client registration.
# If true, no token is needed to register a client.
# If false, dynamical client registration needs a token defined
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
[CANAILLE_OIDC]
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
# A list of tokens that can be used for dynamic client registration
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
# This adds security but may not be supported by all clients.
# REQUIRE_NONCE = true
[OIDC.JWT]
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
# the public key. You can generate a RSA keypair with:
# openssl genrsa -out private.pem 4096
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
# If the variables are unset, and debug mode is enabled,
# a in-memory keypair will be used.
# PRIVATE_KEY_FILE = "/path/to/private.pem"
# PUBLIC_KEY_FILE = "/path/to/public.pem"
# The URI of the identity provider
# ISS = "https://auth.mydomain.tld"
# The key type parameter
# KTY = "RSA"
# The key algorithm
# ALG = "RS256"
# The time the JWT will be valid, in seconds
# EXP = 3600
[OIDC.JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
# SUB = "{{ user.user_name }}"
# NAME = "{{ user.formatted_name }}"
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
# EMAIL = "{{ user.preferred_email }}"
# GIVEN_NAME = "{{ user.given_name }}"
# FAMILY_NAME = "{{ user.family_name }}"
# PREFERRED_USERNAME = "{{ user.display_name }}"
# LOCALE = "{{ user.preferred_language }}"
# ADDRESS = "{{ user.formatted_address }}"
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
# WEBSITE = "{{ user.profile_url }}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.
[SMTP]
# HOST = "localhost"
# PORT = 25
# TLS = false
# SSL = false
# LOGIN = ""
# PASSWORD = ""
# FROM_ADDR = "admin@mydomain.tld"

View file

@ -26,11 +26,11 @@ poetry install --with demo --all-extras
if [ "$BACKEND" = "memory" ]; then
env poetry run honcho --procfile Procfile-memory start
poetry run honcho --procfile Procfile-memory start
elif [ "$BACKEND" = "sql" ]; then
env poetry run honcho --procfile Procfile-sql start
poetry run honcho --procfile Procfile-sql start
elif [ "$BACKEND" = "ldap" ]; then
@ -40,7 +40,7 @@ elif [ "$BACKEND" = "ldap" ]; then
exit 1
fi
env poetry run honcho --procfile Procfile-ldap start
poetry run honcho --procfile Procfile-ldap start
else

View file

@ -34,8 +34,10 @@ extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
"sphinx.ext.viewcode",
"sphinx_enum_extend",
"sphinx_issues",
"sphinx_sitemap",
"sphinxcontrib.autodoc_pydantic",
]
templates_path = ["_templates"]
@ -61,6 +63,7 @@ intersphinx_mapping = {
"flask": ("https://flask.palletsprojects.com", None),
"flask-babel": ("https://python-babel.github.io/flask-babel", None),
"flask-wtf": ("https://flask-wtf.readthedocs.io", None),
"pydantic": ("https://docs.pydantic.dev/latest", None),
}
issues_uri = "https://gitlab.com/yaal/canaille/-/issues/{issue}"
@ -122,3 +125,13 @@ texinfo_documents = [
autosectionlabel_prefix_document = True
autosectionlabel_maxdepth = 2
# -- Options for autodo_pydantic_settings -------------------------------------------
autodoc_pydantic_settings_show_json = False
autodoc_pydantic_settings_show_config_summary = False
autodoc_pydantic_settings_show_config_summary = False
autodoc_pydantic_settings_show_validator_summary = False
autodoc_pydantic_settings_show_validator_members = False
autodoc_pydantic_settings_show_field_summary = False
autodoc_pydantic_field_list_validators = False

View file

@ -1,327 +1,61 @@
Configuration
#############
Here are the different options you can have in your configuration file.
Canaille can be configured either by a environment variables, or by a `toml` configuration file which path is passed in the ``CONFIG`` environment variable.
.. contents::
:local:
Toml file
=========
.. note ::
::
Any configuration entry can be suffixed by *_FILE* and point to the path of
a file that contains the actual value. For instance you could have
``SECRET_KEY_FILE = "/path/to/secret.txt"`` instead of ``SECRET_KEY = "very-secret"``
SECRET_KEY = "very-secret"
Sections
========
[CANAILLE]
NAME = "My organization"
Miscellaneous
-------------
Canaille is based on Flask, so any `flask configuration <https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values>`_ option will be usable with canaille:
[CANAILLE_SQL]
DATABASE_URI = "postgresql://user:password@localhost/database"
...
:SECRET_KEY:
**Required.** The Flask secret key. You should set a random string here.
You can have a look at the :ref:`configuration:Example file` for inspiration.
.. note ::
Environment variables
=====================
Remember that you can also use SECRET_KEY_FILE to store the secret key
outside the configuration file.
In addition, parameters that have not been set in the configuration file can be read from environment variables.
The way environment variables are parsed can be read from the `pydantic-settings documentation <https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values>`_.
:NAME:
*Optional.* The name of your organization. If not set `Canaille` will be used.
Settings will also be read from a local ``.env`` file if present.
:LOGO:
*Optional.* The URL ot the logo of your organization. The default is the canaille logo.
Secret parameters
=================
:FAVICON:
*Optional.* An URL to a favicon. The default is the value of ``LOGO``.
A ``SECRETS_DIR`` environment variable can be passed as an environment variable, being a path to a directory in which are stored files named after the configuration settings.
:THEME:
*Optional.* The name or the path to a canaille theme.
If the value is just a name, the theme should be in a directory with that name in the *themes* directory.
For instance, you can set ``SECRETS_DIR=/run/secrets`` and put your secret key in the file ``/run/secrets/SECRET_KEY``.
:LANGUAGE:
*Optional.* The locale code of the language to use. If not set, the language of the browser will be used.
Parameters
==========
:TIMEZONE:
*Optional.* The timezone in which datetimes will be displayed to the users. If unset, the server timezone will be used.
.. autopydantic_settings:: canaille.app.configuration.RootSettings
:JAVASCRIPT:
*Optional.* Wether javascript is used to smooth the user experience.
.. autopydantic_settings:: canaille.core.configuration.CoreSettings
.. autopydantic_settings:: canaille.core.configuration.SMTPSettings
.. autopydantic_settings:: canaille.core.configuration.ACLSettings
.. auto_autoenum:: canaille.core.configuration.Permission
:HTMX:
*Optional.* Wether `HTMX <https://htmx.org>`_ will be used to accelerate webpages. Defaults to true.
.. autopydantic_settings:: canaille.oidc.configuration.OIDCSettings
:SENTRY_DSN:
*Optional.* A DSN to a sentry instance.
This needs the ``sentry_sdk`` python package to be installed.
This is useful if you want to collect the canaille exceptions in a production environment.
.. autopydantic_settings:: canaille.oidc.configuration.JWTSettings
.. autopydantic_settings:: canaille.oidc.configuration.JWTMappingSettings
:ENABLE_REGISTRATION:
*Optional.* If true, then users can freely create an account
at this instance. If ``EMAIL_CONFIRMATION`` is true, users must confirm
their email before the account is created.
Defaults to false.
.. autopydantic_settings:: canaille.backends.sql.configuration.SQLSettings
.. autopydantic_settings:: canaille.backends.ldap.configuration.LDAPSettings
:EMAIL_CONFIRMATION:
*Optional.* If set to true, users will need to click on
a confirmation link sent by email when they want to add a new email. By default,
this is true if SMTP is configured, else this is false. If explicitely set to
true and SMTP is disabled, the email field will be read-only.
Example file
============
:HIDE_INVALID_LOGINS:
*Optional.* Wether to tell the users if a username exists during failing login attempts.
Defaults to ``True``. This may be a security issue to disable this, as this give a way to malicious people to if an account exists on this canaille instance.
Here is a configuration file example:
:ENABLE_PASSWORD_RECOVERY:
*Optional* Wether the password recovery feature is enabled or not.
Defaults to ``True``.
:INVITATION_EXPIRATION:
*Optional* The validity duration of registration invitations, in seconds.
Defaults to 2 days.
LOGGING
-------
:LEVEL:
*Optional.* The logging level. Must be an either *DEBUG*, *INFO*, *WARNING*, *ERROR* or *CRITICAL*. Defults to *WARNING*.
:PATH:
*Optional.* The log file path. If not set, logs are written in the standard error output.
BACKENDS.SQL
------------
:SQL_DATABASE_URI:
**Required.** The SQL database connection string, as defined in
`SQLAlchemy documentation <https://docs.sqlalchemy.org/en/20/core/engines.html>`_.
BACKENDS.LDAP
-------------
:URI:
**Required.** The URI to the LDAP server.
e.g. ``ldaps://ldad.mydomain.tld``
:ROOT_DN:
**Required.** The root DN of your LDAP server.
e.g. ``dc=mydomain,dc=tld``
:BIND_DN:
**Required.** The LDAP DN to bind with.
e.g. ``cn=admin,dc=mydomain,dc=tld``
:BIND_PW:
**Required.** The LDAP user associated with ``BIND_DN``.
:TIMEOUT:
*Optional.* The time to wait for the LDAP server to respond before considering it is not functional.
:USER_BASE:
**Required.** The DN of the node in which users will be searched for, and created.
e.g. ``ou=users,dc=mydomain,dc=tld``
:USER_CLASS:
*Optional.* The LDAP object class to filter existing users, and create new users.
Can be a list of classes.
Defaults to ``inetOrgPerson``.
:USER_RDN:
*Optional.* The attribute to identify an object in the User DN.
For example, if it has the value ``uid``, users DN will be in the form ``uid=foobar,ou=users,dc=mydomain,dc=tld``.
Defaults to ``cn``.
:USER_FILTER:
*Optional.* The filter to match users on sign in.
Jinja syntax is supported and a `login` variable is available containing
the value passed in the login field.
Defaults to ``(|(uid={{ login }})(mail={{ login }}))``
:GROUP_BASE:
**Required.** The DN where of the node in which LDAP groups will be created and searched for.
e.g. ``ou=groups,dc=mydomain,dc=tld``
:GROUP_CLASS:
*Optional.* The LDAP object class to filter existing groups, and create new groups.
Can be a list of classes.
Defaults to ``groupOfNames``
:GROUP_RDN:
*Optional.* The attribute to identify an object in a group DN.
For example, if it has the value ``cn``, groups DN will be in the form ``cn=foobar,ou=users,dc=mydomain,dc=tld``.
Defaults to ``cn``
:GROUP_NAME_ATTRIBUTE:
*Optional.* The attribute to identify a group in the web interface.
Defaults to ``cn``
ACL
---
You can define access controls that define what users can do on canaille
An access control consists in a ``FILTER`` to match users, a list of ``PERMISSIONS`` that users will be able to perform, and fields users will be able
to ``READ`` and ``WRITE``. Users matching several filters will cumulate permissions.
The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
object that users will be able to read and/or write.
:FILTER:
*Optional.* It can be:
- absent, in which case all the users will have the permissions in this ACL.
- a mapping where keys are user attributes name and the values those user
attribute values. All the values must be matched for the user to be part
of the access control.
- a list of those mappings. If a user values match at least one mapping,
then the user will be part of the access control
Here are some examples:
- ``FILTER = {'user_name': 'admin'}``
- ``FILTER = [{'groups': 'admin'}, {'groups': 'moderators'}]``
:PERMISSIONS:
*Optional.* A list of items the users in the access control will be able to manage. Values can be:
- **edit_self** to allow users to edit their own profile
- **use_oidc** to allow OpenID Connect authentication
- **manage_oidc** to allow OpenID Connect client managements
- **manage_users** to allow other users management
- **manage_groups** to allow group edition and creation
- **delete_account** allows a user to delete his own account. If used with *manage_users*, the user can delete any account
- **impersonate_users** to allow a user to take the identity of another user
:READ:
*Optional.* A list of attributes of ``USER_CLASS`` the user will be able to see, but not edit.
If the users has the ``edit_self`` permission, they will be able to see those fields on their own account.
If the users has the ``manage_users`` permission, the user will be able to see this fields on other users profile.
If the list containts the special ``groups`` field, the user will be able to see the groups he belongs to.
:WRITE:
*Optional.* A list of attributes of ``USER_CLASS`` the user will be able to edit.
If the users has the ``edit_self`` permission, they will be able to edit those fields on their own account.
If the users has the ``manage_users`` permission, they will be able to edit those fields on other users profile.
If the list containts the special ``groups`` field, the user will be able to edit the groups he belongs to.
OIDC
----
:DYNAMIC_CLIENT_REGISTRATION_OPEN:
*Optional.* Wether a token is needed for the RFC7591 dynamical client registration.
If true, no token is needed to register a client.
If false, dynamical client registration needs a token defined
in `DYNAMIC_CLIENT_REGISTRATION_TOKENS``
Defaults to ``False``
:DYNAMIC_CLIENT_REGISTRATION_TOKENS:
*Optional.* A list of tokens that can be used for dynamic client registration
:REQUIRE_NONE:
*Optional.* Forces the nonce exchange during the authentication flows.
This adds security but may not be supported by all clients.
Defaults to ``True``
OIDC.JWT
--------
Canaille needs a key pair to sign the JWT. The installation command will generate a key pair for you, but you can also do it manually. In debug mode, a in-memory keypair will be used.
:PRIVATE_KEY:
**Required.** The content of the private key..
:PUBLIC_KEY:
**Required.** The content of the public key.
.. note ::
Remember that you can also use PRIVATE_KEY_FILE and PUBLIC_KEY_FILE
to store the keys outside the configuration file.
:ISS:
*Optional.* The URI of the identity provider.
Defaults to ``SERVER_NAME`` if set, else the current domain will be used.
e.g. ``https://auth.mydomain.tld``
:KTY:
*Optional.* The key type parameter.
Defaults to ``RSA``.
:ALG:
*Optional.* The key algorithm.
Defaults to ``RS256``.
:EXP:
*Optional.* The time the JWT will be valid, in seconds.
Defaults to ``3600``
OIDC.JWT.MAPPINGS
-----------------
A mapping where keys are JWT claims, and values are LDAP user object attributes.
Attributes are rendered using jinja2, and can use a ``user`` variable.
:SUB:
*Optional.* Defaults to ``{{ user.user_name }}``
:NAME:
*Optional.* Defaults to ``{{ user.cn[0] }}``
:PHONE_NUMBER:
*Optional.* Defaults to ``{{ user.phone_number[0] }}``
:EMAIL:
*Optional.* Defaults to ``{{ user.mail[0] }}``
:GIVEN_NAME:
*Optional.* Defaults to ``{{ user.given_name }}``
:FAMILY_NAME:
*Optional.* Defaults to ``{{ user.family_name }}``
:PREFERRED_USERNAME:
*Optional.* Defaults to ``{{ user.display_name[0] }}``
:LOCALE:
*Optional.* Defaults to ``{{ user.locale }}``
:ADDRESS:
*Optional.* Defaults to ``{{ user.address[0] }}``
:PICTURE:
*Optional.* Defaults to ``{% if user.photo %}{{ url_for('core.account.photo', user_name=user.user_name, field='photo', _external=True) }}{% endif %}``
:WEBSITE:
*Optional.* Defaults to ``{{ user.profile_url }}``
SMTP
----
Canaille needs you to configure a SMTP server to send some mails, including the *I forgot my password* and the *invitation* mails.
Without this section Canaille will still be usable, but all the features related to mail will be disabled.
:HOST:
The SMTP server to connect to.
Defaults to ``localhost``
:PORT:
The port to use with the SMTP connection.
Defaults to ``25``
:TLS:
Whether the SMTP connection use TLS.
Default to ``False``
:SSL:
Whether the SMTP connection use SSL.
Default to ``False``
:LOGIN:
The SMTP server authentication login.
*Optional.*
:PASSWORD:
The SMTP server authentication password.
*Optional.*
:FROM_ADDR:
*Optional.* The mail address to use as the sender for Canaille emails.
Defaults to `admin@<HOSTNAME>` where `HOSTNAME` is the current hostname.
.. literalinclude :: ../canaille/config.sample.toml
:language: toml

View file

@ -11,7 +11,7 @@ Memory
======
Canaille comes with a lightweight inmemory backend by default.
It is used when no other backend has been configured, i.e. when the ``BACKENDS`` configuration parameter is unset or empty.
It is used when no other backend has been configured.
This backend is only for test purpose and should not be used in production environments.
@ -21,18 +21,20 @@ SQL
Canaille can use any database supported by `SQLAlchemy <https://www.sqlalchemy.org/>`_, such as
sqlite, postgresql or mariadb.
It is used when the ``BACKENDS.SQL`` configuration parameter is defined. For instance::
It is used when the ``CANAILLE_SQL`` configuration parameter is defined. For instance::
[BACKENDS.SQL]
[CANAILLE_SQL]
SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
You can find more details on the SQL configuration in the :class:`~canaille.backends.sql.configuration.SQLSettings` section.
LDAP
====
Canaille can use OpenLDAP as its main database.
It is used when the ``BACKENDS.LDAP`` configuration parameter is defined. For instance::
It is used when the ``CANAILLE_LDAP`` configuration parameter is defined. For instance::
[BACKENDS.LDAP]
[CANAILLE_LDAP]
URI = "ldap://ldap"
ROOT_DN = "dc=mydomain,dc=tld"
BIND_DN = "cn=admin,dc=mydomain,dc=tld"
@ -44,6 +46,8 @@ It is used when the ``BACKENDS.LDAP`` configuration parameter is defined. For in
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
You can find more details on the LDAP configuration in the :class:`~canaille.backends.ldap.configuration.LDAPSettings` section.
.. note ::
Currently, only the ``inetOrgPerson`` and ``groupOfNames`` schemas have been tested.
If you want to use different schemas or LDAP servers, adaptations may be needed.

View file

@ -153,12 +153,12 @@ Apache
RewriteRule "^/.well-know/webfinger" "https://auth.mydomain.tld/.well-known/webfinger" [R,L]
</VirtualHost>
Create your first user
======================
Create the first user
=====================
Once canaille is installed, you have several ways to populate the database. The obvious one is by adding
directly users and group into your LDAP directory. You might also want to temporarily enable then
``ENABLE_REGISTRATION`` configuration parameter to allow you to create your first users. Then, if you
directly users and group into your LDAP directory. You might also want to temporarily enable then the
:attr:`~canaille.core.configuration.CoreSettings.ENABLE_REGISTRATION` configuration parameter to allow you to create your first users. Then, if you
have configured your ACLs properly then you will be able to manage users and groups through the Canaille
interface.

View file

@ -6,4 +6,4 @@ The web interface throws unuseful error messages
Unless the current user has admin permissions, or the installation is in debug mode, error messages won't be too technical.
For instance, you can see *The request you made is invalid*.
To enable detailed error messages, you can **temporarily** set the ``DEBUG=true`` configuration parameter.
To enable detailed error messages, you can **temporarily** enable the :attr:`~canaille.core.configuration.RootSettings.DEBUG` configuration parameter.

207
poetry.lock generated
View file

@ -26,6 +26,20 @@ files = [
{file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"},
]
[[package]]
name = "annotated-types"
version = "0.6.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
files = [
{file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"},
{file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"},
]
[package.dependencies]
typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""}
[[package]]
name = "atpublic"
version = "4.0"
@ -70,6 +84,29 @@ files = [
[package.dependencies]
cryptography = "*"
[[package]]
name = "autodoc-pydantic"
version = "2.1.0"
description = "Seamlessly integrate pydantic models in your Sphinx documentation."
optional = false
python-versions = ">=3.8,<4.0.0"
files = [
{file = "autodoc_pydantic-2.1.0-py3-none-any.whl", hash = "sha256:9f1f82ee3667589dfa08b21697be8bbd80b15110e838cd765bb1bf3ce1b0ea8f"},
{file = "autodoc_pydantic-2.1.0.tar.gz", hash = "sha256:3cf1b973e2f5ff0fbbe9b951c11827b5e32d3409e238f7f5782359426ab8d360"},
]
[package.dependencies]
importlib-metadata = {version = ">1", markers = "python_version <= \"3.8\""}
pydantic = ">=2.0,<3.0.0"
pydantic-settings = ">=2.0,<3.0.0"
Sphinx = ">=4.0"
[package.extras]
dev = ["coverage (>=7,<8)", "flake8 (>=3,<4)", "pytest (>=7,<8)", "sphinx-copybutton (>=0.4,<0.5)", "sphinx-rtd-theme (>=1.0,<2.0)", "sphinx-tabs (>=3,<4)", "sphinxcontrib-mermaid (>=0.7,<0.8)", "tox (>=3,<4)"]
docs = ["sphinx-copybutton (>=0.4,<0.5)", "sphinx-rtd-theme (>=1.0,<2.0)", "sphinx-tabs (>=3,<4)", "sphinxcontrib-mermaid (>=0.7,<0.8)"]
erdantic = ["erdantic (>=0.6,<0.7)"]
test = ["coverage (>=7,<8)", "pytest (>=7,<8)"]
[[package]]
name = "babel"
version = "2.14.0"
@ -518,6 +555,17 @@ files = [
dnspython = ">=2.0.0"
idna = ">=2.0.0"
[[package]]
name = "enum-extend"
version = "0.1.1"
description = "Enum base classes that support enum comparsion and auto numbering with doc strings"
optional = false
python-versions = ">=3.4.0"
files = [
{file = "enum-extend-0.1.1.tar.gz", hash = "sha256:943208b2e62535e1a649945ee8dceab4576473a85cbb740ff84b4821492161b1"},
{file = "enum_extend-0.1.1-py3-none-any.whl", hash = "sha256:a6bd4b09e1539d144d433ecf7c7d94def45b8852e5e494b2c31faf618d6a5a17"},
]
[[package]]
name = "exceptiongroup"
version = "1.2.0"
@ -1307,6 +1355,135 @@ files = [
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
[[package]]
name = "pydantic"
version = "2.6.4"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic-2.6.4-py3-none-any.whl", hash = "sha256:cc46fce86607580867bdc3361ad462bab9c222ef042d3da86f2fb333e1d916c5"},
{file = "pydantic-2.6.4.tar.gz", hash = "sha256:b1704e0847db01817624a6b86766967f552dd9dbf3afba4004409f908dcc84e6"},
]
[package.dependencies]
annotated-types = ">=0.4.0"
pydantic-core = "2.16.3"
typing-extensions = ">=4.6.1"
[package.extras]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.16.3"
description = ""
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_core-2.16.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:75b81e678d1c1ede0785c7f46690621e4c6e63ccd9192af1f0bd9d504bbb6bf4"},
{file = "pydantic_core-2.16.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9c865a7ee6f93783bd5d781af5a4c43dadc37053a5b42f7d18dc019f8c9d2bd1"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:162e498303d2b1c036b957a1278fa0899d02b2842f1ff901b6395104c5554a45"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f583bd01bbfbff4eaee0868e6fc607efdfcc2b03c1c766b06a707abbc856187"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b926dd38db1519ed3043a4de50214e0d600d404099c3392f098a7f9d75029ff8"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:716b542728d4c742353448765aa7cdaa519a7b82f9564130e2b3f6766018c9ec"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ad7f7ee1a13d9cb49d8198cd7d7e3aa93e425f371a68235f784e99741561f"},
{file = "pydantic_core-2.16.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bd87f48924f360e5d1c5f770d6155ce0e7d83f7b4e10c2f9ec001c73cf475c99"},
{file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0df446663464884297c793874573549229f9eca73b59360878f382a0fc085979"},
{file = "pydantic_core-2.16.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4df8a199d9f6afc5ae9a65f8f95ee52cae389a8c6b20163762bde0426275b7db"},
{file = "pydantic_core-2.16.3-cp310-none-win32.whl", hash = "sha256:456855f57b413f077dff513a5a28ed838dbbb15082ba00f80750377eed23d132"},
{file = "pydantic_core-2.16.3-cp310-none-win_amd64.whl", hash = "sha256:732da3243e1b8d3eab8c6ae23ae6a58548849d2e4a4e03a1924c8ddf71a387cb"},
{file = "pydantic_core-2.16.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:519ae0312616026bf4cedc0fe459e982734f3ca82ee8c7246c19b650b60a5ee4"},
{file = "pydantic_core-2.16.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b3992a322a5617ded0a9f23fd06dbc1e4bd7cf39bc4ccf344b10f80af58beacd"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d62da299c6ecb04df729e4b5c52dc0d53f4f8430b4492b93aa8de1f541c4aac"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2acca2be4bb2f2147ada8cac612f8a98fc09f41c89f87add7256ad27332c2fda"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b662180108c55dfbf1280d865b2d116633d436cfc0bba82323554873967b340"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e7c6ed0dc9d8e65f24f5824291550139fe6f37fac03788d4580da0d33bc00c97"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1bb0827f56654b4437955555dc3aeeebeddc47c2d7ed575477f082622c49e"},
{file = "pydantic_core-2.16.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e56f8186d6210ac7ece503193ec84104da7ceb98f68ce18c07282fcc2452e76f"},
{file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:936e5db01dd49476fa8f4383c259b8b1303d5dd5fb34c97de194560698cc2c5e"},
{file = "pydantic_core-2.16.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33809aebac276089b78db106ee692bdc9044710e26f24a9a2eaa35a0f9fa70ba"},
{file = "pydantic_core-2.16.3-cp311-none-win32.whl", hash = "sha256:ded1c35f15c9dea16ead9bffcde9bb5c7c031bff076355dc58dcb1cb436c4721"},
{file = "pydantic_core-2.16.3-cp311-none-win_amd64.whl", hash = "sha256:d89ca19cdd0dd5f31606a9329e309d4fcbb3df860960acec32630297d61820df"},
{file = "pydantic_core-2.16.3-cp311-none-win_arm64.whl", hash = "sha256:6162f8d2dc27ba21027f261e4fa26f8bcb3cf9784b7f9499466a311ac284b5b9"},
{file = "pydantic_core-2.16.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f56ae86b60ea987ae8bcd6654a887238fd53d1384f9b222ac457070b7ac4cff"},
{file = "pydantic_core-2.16.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9bd22a2a639e26171068f8ebb5400ce2c1bc7d17959f60a3b753ae13c632975"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4204e773b4b408062960e65468d5346bdfe139247ee5f1ca2a378983e11388a2"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f651dd19363c632f4abe3480a7c87a9773be27cfe1341aef06e8759599454120"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aaf09e615a0bf98d406657e0008e4a8701b11481840be7d31755dc9f97c44053"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e47755d8152c1ab5b55928ab422a76e2e7b22b5ed8e90a7d584268dd49e9c6b"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:500960cb3a0543a724a81ba859da816e8cf01b0e6aaeedf2c3775d12ee49cade"},
{file = "pydantic_core-2.16.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf6204fe865da605285c34cf1172879d0314ff267b1c35ff59de7154f35fdc2e"},
{file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d33dd21f572545649f90c38c227cc8631268ba25c460b5569abebdd0ec5974ca"},
{file = "pydantic_core-2.16.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:49d5d58abd4b83fb8ce763be7794d09b2f50f10aa65c0f0c1696c677edeb7cbf"},
{file = "pydantic_core-2.16.3-cp312-none-win32.whl", hash = "sha256:f53aace168a2a10582e570b7736cc5bef12cae9cf21775e3eafac597e8551fbe"},
{file = "pydantic_core-2.16.3-cp312-none-win_amd64.whl", hash = "sha256:0d32576b1de5a30d9a97f300cc6a3f4694c428d956adbc7e6e2f9cad279e45ed"},
{file = "pydantic_core-2.16.3-cp312-none-win_arm64.whl", hash = "sha256:ec08be75bb268473677edb83ba71e7e74b43c008e4a7b1907c6d57e940bf34b6"},
{file = "pydantic_core-2.16.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:b1f6f5938d63c6139860f044e2538baeee6f0b251a1816e7adb6cbce106a1f01"},
{file = "pydantic_core-2.16.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2a1ef6a36fdbf71538142ed604ad19b82f67b05749512e47f247a6ddd06afdc7"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704d35ecc7e9c31d48926150afada60401c55efa3b46cd1ded5a01bdffaf1d48"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d937653a696465677ed583124b94a4b2d79f5e30b2c46115a68e482c6a591c8a"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9803edf8e29bd825f43481f19c37f50d2b01899448273b3a7758441b512acf8"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72282ad4892a9fb2da25defeac8c2e84352c108705c972db82ab121d15f14e6d"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f752826b5b8361193df55afcdf8ca6a57d0232653494ba473630a83ba50d8c9"},
{file = "pydantic_core-2.16.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4384a8f68ddb31a0b0c3deae88765f5868a1b9148939c3f4121233314ad5532c"},
{file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4b2bf78342c40b3dc830880106f54328928ff03e357935ad26c7128bbd66ce8"},
{file = "pydantic_core-2.16.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:13dcc4802961b5f843a9385fc821a0b0135e8c07fc3d9949fd49627c1a5e6ae5"},
{file = "pydantic_core-2.16.3-cp38-none-win32.whl", hash = "sha256:e3e70c94a0c3841e6aa831edab1619ad5c511199be94d0c11ba75fe06efe107a"},
{file = "pydantic_core-2.16.3-cp38-none-win_amd64.whl", hash = "sha256:ecdf6bf5f578615f2e985a5e1f6572e23aa632c4bd1dc67f8f406d445ac115ed"},
{file = "pydantic_core-2.16.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bda1ee3e08252b8d41fa5537413ffdddd58fa73107171a126d3b9ff001b9b820"},
{file = "pydantic_core-2.16.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:21b888c973e4f26b7a96491c0965a8a312e13be108022ee510248fe379a5fa23"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be0ec334369316fa73448cc8c982c01e5d2a81c95969d58b8f6e272884df0074"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5b6079cc452a7c53dd378c6f881ac528246b3ac9aae0f8eef98498a75657805"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ee8d5f878dccb6d499ba4d30d757111847b6849ae07acdd1205fffa1fc1253c"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7233d65d9d651242a68801159763d09e9ec96e8a158dbf118dc090cd77a104c9"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6119dc90483a5cb50a1306adb8d52c66e447da88ea44f323e0ae1a5fcb14256"},
{file = "pydantic_core-2.16.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:578114bc803a4c1ff9946d977c221e4376620a46cf78da267d946397dc9514a8"},
{file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d8f99b147ff3fcf6b3cc60cb0c39ea443884d5559a30b1481e92495f2310ff2b"},
{file = "pydantic_core-2.16.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4ac6b4ce1e7283d715c4b729d8f9dab9627586dafce81d9eaa009dd7f25dd972"},
{file = "pydantic_core-2.16.3-cp39-none-win32.whl", hash = "sha256:e7774b570e61cb998490c5235740d475413a1f6de823169b4cf94e2fe9e9f6b2"},
{file = "pydantic_core-2.16.3-cp39-none-win_amd64.whl", hash = "sha256:9091632a25b8b87b9a605ec0e61f241c456e9248bfdcf7abdf344fdb169c81cf"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:36fa178aacbc277bc6b62a2c3da95226520da4f4e9e206fdf076484363895d2c"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:dcca5d2bf65c6fb591fff92da03f94cd4f315972f97c21975398bd4bd046854a"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a72fb9963cba4cd5793854fd12f4cfee731e86df140f59ff52a49b3552db241"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60cc1a081f80a2105a59385b92d82278b15d80ebb3adb200542ae165cd7d183"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cbcc558401de90a746d02ef330c528f2e668c83350f045833543cd57ecead1ad"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:fee427241c2d9fb7192b658190f9f5fd6dfe41e02f3c1489d2ec1e6a5ab1e04a"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f4cb85f693044e0f71f394ff76c98ddc1bc0953e48c061725e540396d5c8a2e1"},
{file = "pydantic_core-2.16.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b29eeb887aa931c2fcef5aa515d9d176d25006794610c264ddc114c053bf96fe"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a425479ee40ff021f8216c9d07a6a3b54b31c8267c6e17aa88b70d7ebd0e5e5b"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5c5cbc703168d1b7a838668998308018a2718c2130595e8e190220238addc96f"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b6add4c0b39a513d323d3b93bc173dac663c27b99860dd5bf491b240d26137"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75f76ee558751746d6a38f89d60b6228fa174e5172d143886af0f85aa306fd89"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00ee1c97b5364b84cb0bd82e9bbf645d5e2871fb8c58059d158412fee2d33d8a"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:287073c66748f624be4cef893ef9174e3eb88fe0b8a78dc22e88eca4bc357ca6"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ed25e1835c00a332cb10c683cd39da96a719ab1dfc08427d476bce41b92531fc"},
{file = "pydantic_core-2.16.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:86b3d0033580bd6bbe07590152007275bd7af95f98eaa5bd36f3da219dcd93da"},
{file = "pydantic_core-2.16.3.tar.gz", hash = "sha256:1cac689f80a3abab2d3c0048b29eea5751114054f032a941a32de4c852c59cad"},
]
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pydantic-settings"
version = "2.2.1"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.8"
files = [
{file = "pydantic_settings-2.2.1-py3-none-any.whl", hash = "sha256:0235391d26db4d2190cb9b31051c4b46882d28a51533f97440867f012d4da091"},
{file = "pydantic_settings-2.2.1.tar.gz", hash = "sha256:00b9f6a5e95553590434c0fa01ead0b216c3e10bc54ae02e37f359948643c5ed"},
]
[package.dependencies]
pydantic = ">=2.3.0"
python-dotenv = ">=0.21.0"
[package.extras]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pygments"
version = "2.17.2"
@ -1488,6 +1665,20 @@ files = [
[package.dependencies]
six = ">=1.5"
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "python-ldap"
version = "3.4.4"
@ -1774,6 +1965,20 @@ docs = ["sphinxcontrib-websupport"]
lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"]
test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"]
[[package]]
name = "sphinx-enum-extend"
version = "0.1.3"
description = "Spinx plugin for documenting enum-extend.AutoEnum"
optional = false
python-versions = ">=3.4.0"
files = [
{file = "sphinx-enum-extend-0.1.3.tar.gz", hash = "sha256:c0a73ebb106aae3562247244a759c55c731d2204026648501440ceef4166ff97"},
{file = "sphinx_enum_extend-0.1.3-py3-none-any.whl", hash = "sha256:4f7747119beca6d2408c819b08cadabd26136612bdc9e7aa0f2f8dcd646d689b"},
]
[package.dependencies]
enum-extend = ">=0.1.1"
[[package]]
name = "sphinx-issues"
version = "4.0.0"
@ -2207,4 +2412,4 @@ sql = ["passlib", "sqlalchemy", "sqlalchemy-json", "sqlalchemy-utils"]
[metadata]
lock-version = "2.0"
python-versions = "^3.8"
content-hash = "daf59f1177a7094522a940fa168b6b6dae20d326bcc283836be71d61622c0c21"
content-hash = "d76ba8e5b7c71f02917a01dde1097b232ae4b2be7fda34115705eae886fc1780"

View file

@ -40,6 +40,7 @@ include = ["canaille/translations/*/LC_MESSAGES/*.mo"]
python = "^3.8"
flask = "^3.0.0"
flask-wtf = "^1.2.1"
pydantic-settings = "^2.0.3"
wtforms = "^3.1.1"
# extra : front
@ -64,11 +65,13 @@ passlib = {version = "^1.7.4", optional=true}
sqlalchemy = {version = "^2.0.23", optional=true}
sqlalchemy-json = {version = "^0.7.0", optional=true}
sqlalchemy-utils = {version = "^0.41.1", optional=true}
sphinx-enum-extend = "^0.1.3"
[tool.poetry.group.doc]
optional = true
[tool.poetry.group.doc.dependencies]
autodoc-pydantic = "^2.0.1"
myst-parser = "^2.0.0"
shibuya = "^2024.3.1"
sphinx = "^7.0.0"

View file

@ -8,7 +8,7 @@ def test_check_command(testclient):
def test_check_command_fail(testclient):
testclient.app.config["SMTP"]["HOST"] = "invalid-domain.com"
testclient.app.config["CANAILLE"]["SMTP"]["HOST"] = "invalid-domain.com"
runner = testclient.app.test_cli_runner()
res = runner.invoke(cli, ["check"])
assert res.exit_code == 1, res.stdout

View file

@ -5,68 +5,97 @@ from flask_webtest import TestApp
from canaille import create_app
from canaille.app.configuration import ConfigurationException
from canaille.app.configuration import settings_factory
from canaille.app.configuration import validate
def test_configuration_file_suffix(tmp_path, backend, configuration):
file_path = os.path.join(tmp_path, "secret.txt")
os.environ["SECRETS_DIR"] = str(tmp_path)
file_path = os.path.join(tmp_path, "SECRET_KEY")
with open(file_path, "w") as fd:
fd.write("very-secret")
del configuration["SECRET_KEY"]
configuration["SECRET_KEY_FILE"] = file_path
app = create_app(configuration)
assert "SECRET_KEY_FILE" not in app.config
assert app.config["SECRET_KEY"] == "very-secret"
del os.environ["SECRETS_DIR"]
def test_configuration_from_environment_vars():
os.environ["SECRET_KEY"] = "very-very-secret"
os.environ["CANAILLE__SMTP__FROM_ADDR"] = "user@mydomain.tld"
os.environ["CANAILLE_OIDC__REQUIRE_NONCE"] = "false"
os.environ["CANAILLE_SQL__DATABASE_URI"] = "sqlite:///anything.db"
conf = settings_factory({"TIMEZONE": "UTC"})
assert conf.SECRET_KEY == "very-very-secret"
assert conf.CANAILLE.SMTP.FROM_ADDR == "user@mydomain.tld"
assert conf.CANAILLE_OIDC.REQUIRE_NONCE is False
assert conf.CANAILLE_SQL.DATABASE_URI == "sqlite:///anything.db"
app = create_app({"TIMEZONE": "UTC"})
assert app.config["SECRET_KEY"] == "very-very-secret"
assert app.config["CANAILLE"]["SMTP"]["FROM_ADDR"] == "user@mydomain.tld"
assert app.config["CANAILLE_OIDC"]["REQUIRE_NONCE"] is False
assert app.config["CANAILLE_SQL"]["DATABASE_URI"] == "sqlite:///anything.db"
del os.environ["SECRET_KEY"]
del os.environ["CANAILLE__SMTP__FROM_ADDR"]
del os.environ["CANAILLE_OIDC__REQUIRE_NONCE"]
del os.environ["CANAILLE_SQL__DATABASE_URI"]
def test_smtp_connection_remote_smtp_unreachable(testclient, backend, configuration):
configuration["SMTP"]["HOST"] = "smtp://invalid-smtp.com"
configuration["CANAILLE"]["SMTP"]["HOST"] = "smtp://invalid-smtp.com"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"Could not connect to the SMTP server",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
def test_smtp_connection_remote_smtp_wrong_credentials(
testclient, backend, configuration
):
configuration["SMTP"]["PASSWORD"] = "invalid-password"
configuration["CANAILLE"]["SMTP"]["PASSWORD"] = "invalid-password"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"SMTP authentication failed with user",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
def test_smtp_connection_remote_smtp_no_credentials(testclient, backend, configuration):
del configuration["SMTP"]["LOGIN"]
del configuration["SMTP"]["PASSWORD"]
validate(configuration, validate_remote=True)
del configuration["CANAILLE"]["SMTP"]["LOGIN"]
del configuration["CANAILLE"]["SMTP"]["PASSWORD"]
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=True)
def test_smtp_bad_tls(testclient, backend, smtpd, configuration):
configuration["SMTP"]["TLS"] = False
configuration["CANAILLE"]["SMTP"]["TLS"] = False
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"SMTP AUTH extension not supported by server",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
@pytest.fixture
def themed_testclient(
app,
configuration,
backend,
):
def themed_testclient(app, configuration, backend):
configuration["TESTING"] = True
root = os.path.dirname(os.path.abspath(__file__))
test_theme_path = os.path.join(root, "fixtures", "themes", "test")
configuration["THEME"] = test_theme_path
configuration["CANAILLE"]["THEME"] = test_theme_path
app = create_app(configuration)
@ -82,18 +111,25 @@ def test_theme(testclient, themed_testclient, backend):
def test_invalid_theme(configuration, backend):
validate(configuration, validate_remote=False)
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Cannot find theme",
):
configuration["THEME"] = "invalid"
validate(configuration, validate_remote=False)
configuration["CANAILLE"]["THEME"] = "invalid"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)
with pytest.raises(
ConfigurationException,
match=r"Cannot find theme",
):
configuration["THEME"] = "/path/to/invalid"
validate(configuration, validate_remote=False)
configuration["CANAILLE"]["THEME"] = "/path/to/invalid"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=False)

View file

@ -1,6 +1,5 @@
import os
import pytest
import toml
from flask_webtest import TestApp
@ -32,19 +31,12 @@ def test_environment_configuration(configuration, tmp_path):
os.environ["CONFIG"] = config_path
app = create_app()
assert app.config["SMTP"]["FROM_ADDR"] == "admin@mydomain.tld"
assert app.config["CANAILLE"]["SMTP"]["FROM_ADDR"] == "admin@mydomain.tld"
del os.environ["CONFIG"]
os.remove(config_path)
def test_no_configuration():
with pytest.raises(Exception) as exc:
create_app()
assert "No configuration file found." in str(exc)
def test_file_log_config(configuration, backend, tmp_path, smtpd, admin):
assert len(smtpd.messages) == 0
log_path = os.path.join(tmp_path, "canaille-by-file.log")
@ -54,8 +46,8 @@ def test_file_log_config(configuration, backend, tmp_path, smtpd, admin):
with open(config_file_path, "w") as fd:
fd.write(file_content)
logging_configuration = {**configuration, "LOGGING": config_file_path}
app = create_app(logging_configuration, backend=backend)
configuration["CANAILLE"]["LOGGING"] = str(config_file_path)
app = create_app(configuration, backend=backend)
testclient = TestApp(app)
with testclient.session_transaction() as sess:
@ -77,30 +69,27 @@ def test_file_log_config(configuration, backend, tmp_path, smtpd, admin):
def test_dict_log_config(configuration, backend, tmp_path, smtpd, admin):
assert len(smtpd.messages) == 0
log_path = os.path.join(tmp_path, "canaille-by-dict.log")
logging_configuration = {
**configuration,
"LOGGING": {
"version": 1,
"formatters": {
"default": {
"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
}
},
"handlers": {
"wsgi": {
"class": "logging.handlers.WatchedFileHandler",
"filename": log_path,
"formatter": "default",
}
},
"root": {"level": "DEBUG", "handlers": ["wsgi"]},
"loggers": {
"faker": {"level": "WARNING"},
},
"disable_existing_loggers": False,
configuration["CANAILLE"]["LOGGING"] = {
"version": 1,
"formatters": {
"default": {
"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
}
},
"handlers": {
"wsgi": {
"class": "logging.handlers.WatchedFileHandler",
"filename": log_path,
"formatter": "default",
}
},
"root": {"level": "DEBUG", "handlers": ["wsgi"]},
"loggers": {
"faker": {"level": "WARNING"},
},
"disable_existing_loggers": False,
}
app = create_app(logging_configuration, backend=backend)
app = create_app(configuration, backend=backend)
testclient = TestApp(app)
with testclient.session_transaction() as sess:

View file

@ -11,7 +11,7 @@ from canaille.app.forms import phone_number
def test_datetime_utc_field_no_timezone_is_local_timezone(testclient):
del current_app.config["TIMEZONE"]
current_app.config["CANAILLE"]["TIMEZONE"] = None
class TestForm(wtforms.Form):
dt = DateTimeUTCField()
@ -56,7 +56,7 @@ def test_datetime_utc_field_no_timezone_is_local_timezone(testclient):
def test_datetime_utc_field_utc(testclient):
current_app.config["TIMEZONE"] = "UTC"
current_app.config["CANAILLE"]["TIMEZONE"] = "UTC"
class TestForm(wtforms.Form):
dt = DateTimeUTCField()
@ -99,7 +99,7 @@ def test_datetime_utc_field_utc(testclient):
def test_datetime_utc_field_japan_timezone(testclient):
current_app.config["TIMEZONE"] = "Japan"
current_app.config["CANAILLE"]["TIMEZONE"] = "Japan"
class TestForm(wtforms.Form):
dt = DateTimeUTCField()
@ -143,7 +143,7 @@ def test_datetime_utc_field_japan_timezone(testclient):
def test_datetime_utc_field_invalid_timezone(testclient):
current_app.config["TIMEZONE"] = "invalid"
current_app.config["CANAILLE"]["TIMEZONE"] = "invalid"
class TestForm(wtforms.Form):
dt = DateTimeUTCField()
@ -188,8 +188,8 @@ def test_datetime_utc_field_invalid_timezone(testclient):
def test_fieldlist_add_readonly(testclient, logged_user):
testclient.app.config["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
testclient.app.config["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
logged_user.reload()
res = testclient.get("/profile/user")
@ -207,8 +207,8 @@ def test_fieldlist_add_readonly(testclient, logged_user):
def test_fieldlist_remove_readonly(testclient, logged_user):
testclient.app.config["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
testclient.app.config["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
logged_user.reload()
logged_user.phone_numbers = ["555-555-000", "555-555-111"]

View file

@ -71,7 +71,7 @@ def test_language_config(testclient, logged_user):
res.mustcontain("My profile")
res.mustcontain(no="Mon profil")
testclient.app.config["LANGUAGE"] = "fr"
testclient.app.config["CANAILLE"]["LANGUAGE"] = "fr"
refresh()
res = testclient.get("/profile/user", status=200)
assert res.pyquery("html")[0].attrib["lang"] == "fr"

View file

@ -33,10 +33,10 @@ def test_send_test_email_ssl(testclient, logged_admin, smtpd):
smtpd.config.use_ssl = True
smtpd.config.use_starttls = False
testclient.app.config["SMTP"]["SSL"] = True
testclient.app.config["SMTP"]["TLS"] = False
del testclient.app.config["SMTP"]["LOGIN"]
del testclient.app.config["SMTP"]["PASSWORD"]
testclient.app.config["CANAILLE"]["SMTP"]["SSL"] = True
testclient.app.config["CANAILLE"]["SMTP"]["TLS"] = False
testclient.app.config["CANAILLE"]["SMTP"]["LOGIN"] = None
testclient.app.config["CANAILLE"]["SMTP"]["PASSWORD"] = None
assert len(smtpd.messages) == 0
@ -52,8 +52,8 @@ def test_send_test_email_ssl(testclient, logged_admin, smtpd):
def test_send_test_email_without_credentials(testclient, logged_admin, smtpd):
del testclient.app.config["SMTP"]["LOGIN"]
del testclient.app.config["SMTP"]["PASSWORD"]
testclient.app.config["CANAILLE"]["SMTP"]["LOGIN"] = None
testclient.app.config["CANAILLE"]["SMTP"]["PASSWORD"] = None
assert len(smtpd.messages) == 0
@ -87,7 +87,7 @@ def test_send_test_email_recipient_refused(SMTP, testclient, logged_admin, smtpd
def test_send_test_email_failed(testclient, logged_admin):
testclient.app.config["SMTP"]["TLS"] = False
testclient.app.config["CANAILLE"]["SMTP"]["TLS"] = False
res = testclient.get("/admin/mail")
res.form["email"] = "test@test.com"
with warnings.catch_warnings(record=True):
@ -99,7 +99,7 @@ def test_send_test_email_failed(testclient, logged_admin):
def test_mail_with_default_no_logo(testclient, logged_admin, smtpd):
testclient.app.config["LOGO"] = None
testclient.app.config["CANAILLE"]["LOGO"] = None
assert len(smtpd.messages) == 0
res = testclient.get("/admin/mail")
@ -147,7 +147,7 @@ def test_mail_with_logo_in_http(testclient, logged_admin, smtpd, httpserver):
raw_logo = fd.read()
httpserver.expect_request(logo_path).respond_with_data(raw_logo)
testclient.app.config["LOGO"] = (
testclient.app.config["CANAILLE"]["LOGO"] = (
f"http://{httpserver.host}:{httpserver.port}{logo_path}"
)
assert len(smtpd.messages) == 0
@ -183,7 +183,7 @@ def test_mail_debug_pages(testclient, logged_admin):
def test_custom_from_addr(testclient, user, smtpd):
testclient.app.config["NAME"] = "My Canaille"
testclient.app.config["CANAILLE"]["NAME"] = "My Canaille"
res = testclient.get("/reset", status=200)
res.form["login"] = "user"
res = res.form.submit(status=200)
@ -192,7 +192,7 @@ def test_custom_from_addr(testclient, user, smtpd):
def test_default_from_addr(testclient, user, smtpd):
del testclient.app.config["SMTP"]["FROM_ADDR"]
testclient.app.config["CANAILLE"]["SMTP"]["FROM_ADDR"] = None
res = testclient.get("/reset", status=200)
res.form["login"] = "user"
res = res.form.submit(status=200)
@ -202,7 +202,7 @@ def test_default_from_addr(testclient, user, smtpd):
def test_default_with_no_flask_server_name(configuration, user, smtpd, backend):
del configuration["SERVER_NAME"]
del configuration["SMTP"]["FROM_ADDR"]
configuration["CANAILLE"]["SMTP"]["FROM_ADDR"] = None
app = create_app(configuration, backend=backend)
testclient = TestApp(app)
@ -215,7 +215,7 @@ def test_default_with_no_flask_server_name(configuration, user, smtpd, backend):
def test_default_from_flask_server_name(configuration, user, smtpd, backend):
app = create_app(configuration, backend=backend)
del app.config["SMTP"]["FROM_ADDR"]
app.config["CANAILLE"]["SMTP"]["FROM_ADDR"] = None
app.config["SERVER_NAME"] = "foobar.tld"
testclient = TestApp(app)

View file

@ -1,5 +1,6 @@
import pytest
from canaille.app.configuration import settings_factory
from canaille.backends.ldap.backend import Backend
from tests.backends.ldap import CustomSlapdObject
@ -27,25 +28,25 @@ def slapd_server():
@pytest.fixture
def ldap_configuration(configuration, slapd_server):
configuration["BACKENDS"] = {
"LDAP": {
"ROOT_DN": slapd_server.suffix,
"URI": slapd_server.ldap_uri,
"BIND_DN": slapd_server.root_dn,
"BIND_PW": slapd_server.root_pw,
"USER_BASE": "ou=users",
"USER_RDN": "uid",
"USER_FILTER": "(uid={{ login }})",
"GROUP_BASE": "ou=groups",
"TIMEOUT": 0.1,
},
configuration["CANAILLE_LDAP"] = {
"ROOT_DN": slapd_server.suffix,
"URI": slapd_server.ldap_uri,
"BIND_DN": slapd_server.root_dn,
"BIND_PW": slapd_server.root_pw,
"USER_BASE": "ou=users",
"USER_RDN": "uid",
"USER_FILTER": "(uid={{ login }})",
"GROUP_BASE": "ou=groups",
"TIMEOUT": 0.1,
}
yield configuration
del configuration["BACKENDS"]
del configuration["CANAILLE_LDAP"]
@pytest.fixture
def ldap_backend(slapd_server, ldap_configuration):
backend = Backend(ldap_configuration)
config_obj = settings_factory(ldap_configuration)
config_dict = config_obj.model_dump()
backend = Backend(config_dict)
with backend.session():
yield backend

View file

@ -3,7 +3,7 @@ import pytest
@pytest.fixture
def configuration(ldap_configuration):
ldap_configuration["BACKENDS"]["LDAP"]["USER_RDN"] = "mail"
ldap_configuration["CANAILLE_LDAP"]["USER_RDN"] = "mail"
yield ldap_configuration

View file

@ -13,7 +13,7 @@ def test_ldap_connection_remote_ldap_unreachable(configuration):
app = create_app(configuration)
testclient = TestApp(app)
app.config["BACKENDS"]["LDAP"]["URI"] = "ldap://invalid-ldap.com"
app.config["CANAILLE_LDAP"]["URI"] = "ldap://invalid-ldap.com"
app.config["DEBUG"] = False
res = testclient.get("/", status=500, expect_errors=True)
@ -24,7 +24,7 @@ def test_ldap_connection_remote_ldap_wrong_credentials(configuration):
app = create_app(configuration)
testclient = TestApp(app)
app.config["BACKENDS"]["LDAP"]["BIND_PW"] = "invalid-password"
app.config["CANAILLE_LDAP"]["BIND_PW"] = "invalid-password"
app.config["DEBUG"] = False
res = testclient.get("/", status=500, expect_errors=True)

View file

@ -2,6 +2,7 @@ import pytest
from flask_webtest import TestApp
from canaille import create_app
from canaille.app.configuration import settings_factory
from canaille.app.installation import InstallationException
from canaille.backends.ldap.backend import Backend
from canaille.backends.ldap.ldapobject import LDAPObject
@ -46,61 +47,67 @@ def test_setup_ldap_tree(slapd_server, configuration):
def test_install_schemas(configuration, slapd_server):
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = slapd_server.root_dn
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = slapd_server.root_pw
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
configuration["CANAILLE_LDAP"]["BIND_DN"] = slapd_server.root_dn
configuration["CANAILLE_LDAP"]["BIND_PW"] = slapd_server.root_pw
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
Backend.setup_schemas(configuration)
Backend.setup_schemas(config_dict)
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" in LDAPObject.ldap_object_classes(force=True)
def test_install_schemas_twice(configuration, slapd_server):
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = slapd_server.root_dn
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = slapd_server.root_pw
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
configuration["CANAILLE_LDAP"]["BIND_DN"] = slapd_server.root_dn
configuration["CANAILLE_LDAP"]["BIND_PW"] = slapd_server.root_pw
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
Backend.setup_schemas(configuration)
Backend.setup_schemas(config_dict)
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" in LDAPObject.ldap_object_classes(force=True)
Backend.setup_schemas(configuration)
Backend.setup_schemas(config_dict)
def test_install_no_permissions_to_install_schemas(configuration, slapd_server):
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = (
"uid=admin,ou=users,dc=mydomain,dc=tld"
)
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = "admin"
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
configuration["CANAILLE_LDAP"]["BIND_DN"] = "uid=admin,ou=users,dc=mydomain,dc=tld"
configuration["CANAILLE_LDAP"]["BIND_PW"] = "admin"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
with pytest.raises(InstallationException):
Backend.setup_schemas(configuration)
Backend.setup_schemas(config_dict)
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
def test_install_schemas_command(configuration, slapd_server):
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = slapd_server.root_dn
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = slapd_server.root_pw
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
configuration["CANAILLE_LDAP"]["BIND_DN"] = slapd_server.root_dn
configuration["CANAILLE_LDAP"]["BIND_PW"] = slapd_server.root_pw
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
testclient = TestApp(create_app(configuration, validate=False))
@ -108,5 +115,5 @@ def test_install_schemas_command(configuration, slapd_server):
res = runner.invoke(cli, ["install"])
assert res.exit_code == 0, res.stdout
with Backend(configuration).session():
with Backend(config_dict).session():
assert "oauthClient" in LDAPObject.ldap_object_classes(force=True)

View file

@ -6,6 +6,7 @@ import pytest
from canaille.app import models
from canaille.app.configuration import ConfigurationException
from canaille.app.configuration import settings_factory
from canaille.app.configuration import validate
from canaille.backends.ldap.backend import setup_ldap_models
from canaille.backends.ldap.ldapobject import LDAPObject
@ -194,7 +195,7 @@ def test_guess_object_from_dn(backend, testclient, foo_group):
def test_object_class_update(backend, testclient):
testclient.app.config["BACKENDS"]["LDAP"]["USER_CLASS"] = ["inetOrgPerson"]
testclient.app.config["CANAILLE_LDAP"]["USER_CLASS"] = ["inetOrgPerson"]
setup_ldap_models(testclient.app.config)
user1 = models.User(cn="foo1", sn="bar1", user_name="baz1")
@ -205,7 +206,7 @@ def test_object_class_update(backend, testclient):
"inetOrgPerson"
]
testclient.app.config["BACKENDS"]["LDAP"]["USER_CLASS"] = [
testclient.app.config["CANAILLE_LDAP"]["USER_CLASS"] = [
"inetOrgPerson",
"extensibleObject",
]
@ -241,34 +242,47 @@ def test_object_class_update(backend, testclient):
def test_ldap_connection_no_remote(testclient, configuration):
validate(configuration)
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict)
def test_ldap_connection_remote(testclient, configuration, backend):
validate(configuration, validate_remote=True)
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
validate(config_dict, validate_remote=True)
def test_ldap_connection_remote_ldap_unreachable(testclient, configuration):
configuration["BACKENDS"]["LDAP"]["URI"] = "ldap://invalid-ldap.com"
configuration["CANAILLE_LDAP"]["URI"] = "ldap://invalid-ldap.com"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"Could not connect to the LDAP server",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
def test_ldap_connection_remote_ldap_wrong_credentials(testclient, configuration):
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = "invalid-password"
configuration["CANAILLE_LDAP"]["BIND_PW"] = "invalid-password"
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"LDAP authentication failed with user",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
def test_ldap_cannot_create_users(testclient, configuration, backend):
from canaille.core.models import User
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
def fake_init(*args, **kwarg):
raise ldap.INSUFFICIENT_ACCESS
@ -277,12 +291,15 @@ def test_ldap_cannot_create_users(testclient, configuration, backend):
ConfigurationException,
match=r"cannot create users at",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
def test_ldap_cannot_create_groups(testclient, configuration, backend):
from canaille.core.models import Group
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
def fake_init(*args, **kwarg):
raise ldap.INSUFFICIENT_ACCESS
@ -291,23 +308,23 @@ def test_ldap_cannot_create_groups(testclient, configuration, backend):
ConfigurationException,
match=r"cannot create groups at",
):
validate(configuration, validate_remote=True)
validate(config_dict, validate_remote=True)
def test_login_placeholder(testclient):
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = "(uid={{ login }})"
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = "(uid={{ login }})"
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
assert placeholder == "jdoe"
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = "(cn={{ login }})"
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = "(cn={{ login }})"
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
assert placeholder == "John Doe"
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = "(mail={{ login }})"
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = "(mail={{ login }})"
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
assert placeholder == "john@doe.com"
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = (
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = (
"(|(uid={{ login }})(mail={{ login }}))"
)
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]

View file

@ -1,19 +1,20 @@
import pytest
from canaille.app.configuration import settings_factory
from canaille.backends.sql.backend import Backend
@pytest.fixture
def sqlalchemy_configuration(configuration):
configuration["BACKENDS"] = {
"SQL": {"SQL_DATABASE_URI": "sqlite:///:memory:"},
}
configuration["CANAILLE_SQL"] = {"DATABASE_URI": "sqlite:///:memory:"}
yield configuration
del configuration["BACKENDS"]
del configuration["CANAILLE_SQL"]
@pytest.fixture
def sql_backend(sqlalchemy_configuration):
backend = Backend(sqlalchemy_configuration)
config_obj = settings_factory(sqlalchemy_configuration)
config_dict = config_obj.model_dump()
backend = Backend(config_dict)
with backend.session(init=True):
yield backend

View file

@ -10,7 +10,7 @@ def test_required_methods(testclient):
with pytest.raises(NotImplementedError):
BaseBackend.validate({})
backend = BaseBackend(testclient.app.config)
backend = BaseBackend(testclient.app.config["CANAILLE"])
with pytest.raises(NotImplementedError):
backend.has_account_lockability()

View file

@ -62,64 +62,70 @@ def configuration(smtpd):
conf = {
"SECRET_KEY": gen_salt(24),
"SERVER_NAME": "canaille.test",
"JAVASCRIPT": False,
"LOGO": "/static/img/canaille-head.webp",
"TIMEZONE": "UTC",
"ACL": {
"DEFAULT": {
"READ": ["user_name", "groups"],
"PERMISSIONS": ["edit_self", "use_oidc"],
"WRITE": [
"emails",
"given_name",
"photo",
"family_name",
"display_name",
"password",
"phone_numbers",
"formatted_address",
"street",
"postal_code",
"locality",
"region",
"employee_number",
"department",
"preferred_language",
"title",
"organization",
"lock_date",
],
"PREFERRED_URL_SCHEME": "http",
"CANAILLE": {
"JAVASCRIPT": False,
"LOGO": "/static/img/canaille-head.webp",
"TIMEZONE": "UTC",
"ACL": {
"DEFAULT": {
"READ": ["user_name", "groups"],
"PERMISSIONS": ["edit_self", "use_oidc"],
"WRITE": [
"emails",
"given_name",
"photo",
"family_name",
"display_name",
"password",
"phone_numbers",
"formatted_address",
"street",
"postal_code",
"locality",
"region",
"employee_number",
"department",
"preferred_language",
"title",
"organization",
"lock_date",
],
},
"ADMIN": {
"FILTER": [{"user_name": "admin"}, {"family_name": "admin"}],
"PERMISSIONS": [
"manage_users",
"manage_oidc",
"delete_account",
"impersonate_users",
"manage_groups",
],
"WRITE": [
"groups",
"lock_date",
],
},
"MODERATOR": {
"FILTER": [
{"user_name": "moderator"},
{"family_name": "moderator"},
],
"PERMISSIONS": ["manage_users", "manage_groups", "delete_account"],
"WRITE": [
"groups",
],
},
},
"ADMIN": {
"FILTER": [{"user_name": "admin"}, {"family_name": "admin"}],
"PERMISSIONS": [
"manage_users",
"manage_oidc",
"delete_account",
"impersonate_users",
"manage_groups",
],
"WRITE": [
"groups",
"lock_date",
],
"SMTP": {
"HOST": smtpd.hostname,
"PORT": smtpd.port,
"TLS": smtpd.config.use_starttls,
"SSL": smtpd.config.use_ssl,
"LOGIN": smtpd.config.login_username,
"PASSWORD": smtpd.config.login_password,
"FROM_ADDR": "admin@mydomain.tld",
},
"MODERATOR": {
"FILTER": [{"user_name": "moderator"}, {"family_name": "moderator"}],
"PERMISSIONS": ["manage_users", "manage_groups", "delete_account"],
"WRITE": [
"groups",
],
},
},
"SMTP": {
"HOST": smtpd.hostname,
"PORT": smtpd.port,
"TLS": smtpd.config.use_starttls,
"SSL": smtpd.config.use_ssl,
"LOGIN": smtpd.config.login_username,
"PASSWORD": smtpd.config.login_password,
"FROM_ADDR": "admin@mydomain.tld",
},
}
return conf

View file

@ -14,7 +14,7 @@ def test_index(testclient, user):
res = testclient.get("/", status=302)
assert res.location == "/profile/user"
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
g.user.reload()
res = testclient.get("/", status=302)
assert res.location == "/about"
@ -284,7 +284,7 @@ def test_impersonate(testclient, logged_admin, user):
def test_wrong_login(testclient, user):
testclient.app.config["HIDE_INVALID_LOGINS"] = True
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = True
res = testclient.get("/login", status=200)
res.form["login"] = "invalid"
@ -295,7 +295,7 @@ def test_wrong_login(testclient, user):
res = res.form.submit(status=200)
res.mustcontain(no="The login &#39;invalid&#39; does not exist")
testclient.app.config["HIDE_INVALID_LOGINS"] = False
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = False
res = testclient.get("/login", status=200)
res.form["login"] = "invalid"
@ -341,11 +341,11 @@ def test_user_self_deletion(testclient, backend):
with testclient.session_transaction() as sess:
sess["user_id"] = [user.id]
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
res = testclient.get("/profile/temp/settings")
res.mustcontain(no="Delete my account")
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = [
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = [
"edit_self",
"delete_account",
]
@ -366,7 +366,7 @@ def test_user_self_deletion(testclient, backend):
with testclient.session_transaction() as sess:
assert not sess.get("user_id")
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
def test_account_locking(user, backend):

View file

@ -11,7 +11,7 @@ from canaille.core.endpoints.account import RegistrationPayload
def test_confirmation_disabled_email_editable(testclient, backend, logged_user):
"""If email confirmation is disabled, users should be able to pick any
email."""
testclient.app.config["EMAIL_CONFIRMATION"] = False
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
@ -36,8 +36,8 @@ def test_confirmation_unset_smtp_disabled_email_editable(
"""If email confirmation is unset and no SMTP server has been configured,
then email confirmation cannot be enabled, thus users must be able to pick
any email."""
del testclient.app.config["SMTP"]
testclient.app.config["EMAIL_CONFIRMATION"] = None
del testclient.app.config["CANAILLE"]["SMTP"]
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = None
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
@ -61,8 +61,8 @@ def test_confirmation_enabled_smtp_disabled_readonly(testclient, backend, logged
In doubt, users cannot edit their emails.
"""
del testclient.app.config["SMTP"]
testclient.app.config["EMAIL_CONFIRMATION"] = True
del testclient.app.config["CANAILLE"]["SMTP"]
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = True
res = testclient.get("/profile/user")
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
@ -77,7 +77,7 @@ def test_confirmation_unset_smtp_enabled_email_admin_editable(
):
"""Administrators should be able to edit user email addresses, even when
email confirmation is unset and SMTP is configured."""
testclient.app.config["EMAIL_CONFIRMATION"] = None
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = None
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
@ -100,8 +100,8 @@ def test_confirmation_enabled_smtp_disabled_admin_editable(
):
"""Administrators should be able to edit user email addresses, even when
email confirmation is enabled and SMTP is disabled."""
testclient.app.config["EMAIL_CONFIRMATION"] = True
del testclient.app.config["SMTP"]
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = True
del testclient.app.config["CANAILLE"]["SMTP"]
res = testclient.get("/profile/user")
assert "readonly" not in res.form["emails-0"].attrs
@ -124,7 +124,7 @@ def test_confirmation_unset_smtp_enabled_email_user_validation(
):
"""If email confirmation is unset and there is a SMTP server configured,
then users emails should be validated by sending a confirmation email."""
testclient.app.config["EMAIL_CONFIRMATION"] = None
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = None
with freezegun.freeze_time("2020-01-01 01:00:00"):
res = testclient.get("/login")
@ -165,9 +165,8 @@ def test_confirmation_unset_smtp_enabled_email_user_validation(
)
assert len(smtpd.messages) == 1
assert email_confirmation_url in str(smtpd.messages[0].get_payload()[0]).replace(
"=\n", ""
)
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
assert email_confirmation_url in email_content
with freezegun.freeze_time("2020-01-01 03:00:00"):
res = testclient.get(email_confirmation_url)
@ -455,7 +454,7 @@ def test_edition_forced_mail(testclient, logged_user):
def test_invitation_form_mail_field_readonly(testclient):
"""Tests that the email field is readonly in the invitation form creation
if email confirmation is enabled."""
testclient.app.config["EMAIL_CONFIRMATION"] = True
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = True
payload = RegistrationPayload(
datetime.datetime.now(datetime.timezone.utc).isoformat(),
@ -474,7 +473,7 @@ def test_invitation_form_mail_field_readonly(testclient):
def test_invitation_form_mail_field_writable(testclient):
"""Tests that the email field is writable in the invitation form creation
if email confirmation is disabled."""
testclient.app.config["EMAIL_CONFIRMATION"] = False
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
payload = RegistrationPayload(
datetime.datetime.now(datetime.timezone.utc).isoformat(),

View file

@ -2,7 +2,7 @@ from unittest import mock
def test_password_forgotten_disabled(smtpd, testclient, user):
testclient.app.config["ENABLE_PASSWORD_RECOVERY"] = False
testclient.app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"] = False
testclient.get("/reset", status=404)
testclient.get("/reset/user/hash", status=404)
@ -54,7 +54,7 @@ def test_password_forgotten_invalid_form(smtpd, testclient, user):
def test_password_forgotten_invalid(smtpd, testclient, user):
testclient.app.config["HIDE_INVALID_LOGINS"] = True
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = True
res = testclient.get("/reset", status=200)
res.form["login"] = "i-dont-really-exist"
@ -65,7 +65,7 @@ def test_password_forgotten_invalid(smtpd, testclient, user):
) in res.flashes
res.mustcontain(no="The login &#39;i-dont-really-exist&#39; does not exist")
testclient.app.config["HIDE_INVALID_LOGINS"] = False
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = False
res = testclient.get("/reset", status=200)
res.form["login"] = "i-dont-really-exist"
@ -80,10 +80,10 @@ def test_password_forgotten_invalid(smtpd, testclient, user):
def test_password_forgotten_invalid_when_user_cannot_self_edit(smtpd, testclient, user):
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
user.reload()
testclient.app.config["HIDE_INVALID_LOGINS"] = False
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = False
res = testclient.get("/reset", status=200)
res.form["login"] = "user"
@ -97,7 +97,7 @@ def test_password_forgotten_invalid_when_user_cannot_self_edit(smtpd, testclient
"The user 'John (johnny) Doe' does not have permissions to update their password. We cannot send a password reset email.",
) in res.flashes
testclient.app.config["HIDE_INVALID_LOGINS"] = True
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = True
user.reload()
res = testclient.get("/reset", status=200)

View file

@ -290,7 +290,7 @@ def test_unavailable_if_no_smtp(testclient, logged_admin):
res.mustcontain("Invite")
testclient.get("/invite")
del testclient.app.config["SMTP"]
del testclient.app.config["CANAILLE"]["SMTP"]
res = testclient.get("/users")
res.mustcontain(no="Invite")
@ -302,7 +302,7 @@ def test_unavailable_if_no_smtp(testclient, logged_admin):
def test_groups_are_saved_even_when_user_does_not_have_read_permission(
testclient, foo_group
):
testclient.app.config["ACL"]["DEFAULT"]["READ"] = [
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"] = [
"user_name"
] # remove groups from default read permissions

View file

@ -78,7 +78,7 @@ def test_unavailable_if_no_smtp(testclient, user):
testclient.get("/reset", status=200)
del testclient.app.config["SMTP"]
del testclient.app.config["CANAILLE"]["SMTP"]
res = testclient.get("/login")
res.mustcontain(no="Forgotten password")

View file

@ -1,7 +1,9 @@
def test_group_permissions_by_id(testclient, user, foo_group):
assert not user.can_manage_users
testclient.app.config["ACL"]["ADMIN"]["FILTER"] = {"groups": foo_group.id}
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {
"groups": foo_group.id
}
user.reload()
assert user.can_manage_users
@ -10,7 +12,9 @@ def test_group_permissions_by_id(testclient, user, foo_group):
def test_group_permissions_by_display_name(testclient, user, foo_group):
assert not user.can_manage_users
testclient.app.config["ACL"]["ADMIN"]["FILTER"] = {"groups": foo_group.display_name}
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {
"groups": foo_group.display_name
}
user.reload()
assert user.can_manage_users
@ -19,7 +23,7 @@ def test_group_permissions_by_display_name(testclient, user, foo_group):
def test_invalid_group_permission(testclient, user, foo_group):
assert not user.can_manage_users
testclient.app.config["ACL"]["ADMIN"]["FILTER"] = {"groups": "invalid"}
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {"groups": "invalid"}
user.reload()
assert not user.can_manage_users

View file

@ -7,7 +7,7 @@ from canaille.core.populate import fake_users
@pytest.fixture
def configuration(configuration):
configuration["EMAIL_CONFIRMATION"] = False
configuration["CANAILLE"]["EMAIL_CONFIRMATION"] = False
return configuration
@ -86,7 +86,8 @@ def test_user_list_search_only_allowed_fields(
res.mustcontain(user.formatted_name)
res.mustcontain(no=moderator.formatted_name)
testclient.app.config["ACL"]["DEFAULT"]["READ"].remove("user_name")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].remove("user_name")
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["READ"].remove("user_name")
g.user.reload()
form = res.forms["search"]
@ -103,11 +104,11 @@ def test_edition_permission(
logged_user,
admin,
):
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
logged_user.reload()
testclient.get("/profile/user", status=404)
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
g.user.reload()
testclient.get("/profile/user", status=200)
@ -202,7 +203,7 @@ def test_field_permissions_none(testclient, logged_user):
logged_user.phone_numbers = ["555-666-777"]
logged_user.save()
testclient.app.config["ACL"]["DEFAULT"] = {
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"] = {
"READ": ["user_name"],
"WRITE": [],
"PERMISSIONS": ["edit_self"],
@ -230,7 +231,7 @@ def test_field_permissions_read(testclient, logged_user):
logged_user.phone_numbers = ["555-666-777"]
logged_user.save()
testclient.app.config["ACL"]["DEFAULT"] = {
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"] = {
"READ": ["user_name", "phone_numbers"],
"WRITE": [],
"PERMISSIONS": ["edit_self"],
@ -257,7 +258,7 @@ def test_field_permissions_write(testclient, logged_user):
logged_user.phone_numbers = ["555-666-777"]
logged_user.save()
testclient.app.config["ACL"]["DEFAULT"] = {
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"] = {
"READ": ["user_name"],
"WRITE": ["phone_numbers"],
"PERMISSIONS": ["edit_self"],
@ -386,9 +387,9 @@ def test_inline_validation(testclient, logged_admin, user):
def test_inline_validation_keep_indicators(testclient, logged_admin, user):
testclient.app.config["ACL"]["DEFAULT"]["WRITE"].remove("display_name")
testclient.app.config["ACL"]["DEFAULT"]["READ"].append("display_name")
testclient.app.config["ACL"]["ADMIN"]["WRITE"].append("display_name")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["WRITE"].remove("display_name")
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].append("display_name")
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["WRITE"].append("display_name")
logged_admin.reload()
user.reload()

View file

@ -65,7 +65,7 @@ def test_edition_without_groups(
admin,
):
res = testclient.get("/profile/user/settings", status=200)
testclient.app.config["ACL"]["DEFAULT"]["READ"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"] = []
res = res.form.submit(name="action", value="edit-settings")
assert res.flashes == [("success", "Profile updated successfully.")]
@ -303,11 +303,11 @@ def test_edition_permission(
logged_user,
admin,
):
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
logged_user.reload()
testclient.get("/profile/user/settings", status=404)
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
g.user.reload()
testclient.get("/profile/user/settings", status=200)

View file

@ -9,8 +9,8 @@ from canaille.core.endpoints.account import RegistrationPayload
def test_registration_without_email_validation(testclient, backend, foo_group):
"""Tests a nominal registration without email validation."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["EMAIL_CONFIRMATION"] = False
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
assert not models.User.query(user_name="newuser")
res = testclient.get(url_for("core.account.registration"), status=200)
@ -29,7 +29,7 @@ def test_registration_without_email_validation(testclient, backend, foo_group):
def test_registration_with_email_validation(testclient, backend, smtpd, foo_group):
"""Tests a nominal registration with email validation."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
with freezegun.freeze_time("2020-01-01 02:00:00"):
res = testclient.get(url_for("core.account.join"))
@ -82,9 +82,9 @@ def test_registration_with_email_already_taken(
testclient, backend, smtpd, user, foo_group
):
"""Be sure to not leak email existence if HIDE_INVALID_LOGINS is true."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
testclient.app.config["HIDE_INVALID_LOGINS"] = True
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = True
res = testclient.get(url_for("core.account.join"))
res.form["email"] = "john@doe.com"
res = res.form.submit()
@ -95,7 +95,7 @@ def test_registration_with_email_already_taken(
)
]
testclient.app.config["HIDE_INVALID_LOGINS"] = False
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = False
res = testclient.get(url_for("core.account.join"))
res.form["email"] = "john@doe.com"
res = res.form.submit()
@ -107,35 +107,35 @@ def test_registration_with_email_validation_needs_a_valid_link(
testclient, backend, smtpd, foo_group
):
"""Tests a nominal registration without email validation."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
testclient.get(url_for("core.account.registration"), status=403)
def test_join_page_registration_disabled(testclient, backend, smtpd, foo_group):
"""The join page should not be available if registration is disabled."""
testclient.app.config["ENABLE_REGISTRATION"] = False
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = False
testclient.get(url_for("core.account.join"), status=404)
def test_join_page_email_confirmation_disabled(testclient, backend, smtpd, foo_group):
"""The join page should directly redirect to the registration page if email
confirmation is disabled."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["EMAIL_CONFIRMATION"] = False
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
res = testclient.get(url_for("core.account.join"), status=302)
assert res.location == url_for("core.account.registration")
def test_join_page_already_logged_in(testclient, backend, logged_user, foo_group):
"""The join page should not be accessible for logged users."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
testclient.get(url_for("core.account.join"), status=403)
@mock.patch("smtplib.SMTP")
def test_registration_mail_error(SMTP, testclient, backend, smtpd, foo_group):
"""Display an error message if the registration mail could not be sent."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
SMTP.side_effect = mock.Mock(side_effect=OSError("unit test mail error"))
res = testclient.get(url_for("core.account.join"))
res.form["email"] = "foo@bar.com"

View file

@ -2,13 +2,13 @@ from canaille.oidc.installation import install
def test_install_keypair(configuration):
del configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
del configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
del configuration["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"]
del configuration["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
install(configuration, debug=False)
assert "PRIVATE_KEY" not in configuration["OIDC"]["JWT"]
assert "PUBLIC_KEY" not in configuration["OIDC"]["JWT"]
assert "PRIVATE_KEY" not in configuration["CANAILLE_OIDC"]["JWT"]
assert "PUBLIC_KEY" not in configuration["CANAILLE_OIDC"]["JWT"]
install(configuration, debug=True)
assert configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
assert configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
assert configuration["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"]
assert configuration["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]

View file

@ -28,17 +28,14 @@ def keypair():
@pytest.fixture
def configuration(configuration, keypair):
private_key, public_key = keypair
conf = {
**configuration,
"OIDC": {
"JWT": {
"PUBLIC_KEY": public_key,
"PRIVATE_KEY": private_key,
"ISS": "https://auth.mydomain.tld",
}
},
configuration["CANAILLE_OIDC"] = {
"JWT": {
"PUBLIC_KEY": public_key,
"PRIVATE_KEY": private_key,
"ISS": "https://auth.mydomain.tld",
}
}
return conf
return configuration
@pytest.fixture

View file

@ -9,12 +9,12 @@ def test_index(testclient, user):
res = testclient.get("/", status=302)
assert res.location == "/profile/user"
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = ["use_oidc"]
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = ["use_oidc"]
g.user.reload()
res = testclient.get("/", status=302)
assert res.location == "/consent/"
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
g.user.reload()
res = testclient.get("/", status=302)
assert res.location == "/about"

View file

@ -587,7 +587,7 @@ def test_authorization_code_flow_when_consent_already_given_but_for_a_smaller_sc
def test_authorization_code_flow_but_user_cannot_use_oidc(
testclient, user, client, keypair, trusted_client
):
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
user.reload()
res = testclient.get(
@ -626,7 +626,7 @@ def test_nonce_required_in_oidc_requests(testclient, logged_user, client):
def test_nonce_not_required_in_oauth_requests(testclient, logged_user, client):
assert not models.Consent.query()
testclient.app.config["REQUIRE_NONCE"] = False
testclient.app.config["CANAILLE_OIDC"]["REQUIRE_NONCE"] = False
res = testclient.get(
"/oauth/authorize",
@ -925,7 +925,7 @@ def test_token_custom_expiration_date(testclient, logged_user, client, keypair):
"client_credentials": 4000,
"urn:ietf:params:oauth:grant-type:jwt-bearer": 5000,
}
testclient.app.config["OIDC"]["JWT"]["EXP"] = 6000
testclient.app.config["CANAILLE_OIDC"]["JWT"]["EXP"] = 6000
setup_oauth(testclient.app)
res = testclient.get(

View file

@ -103,7 +103,7 @@ def test_prompt_no_consent(testclient, logged_user, client):
def test_prompt_create_logged(testclient, logged_user, client):
"""If prompt=create and user is already logged in, then go straight to the
consent page."""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
consent = models.Consent(
consent_id=str(uuid.uuid4()),
@ -164,7 +164,7 @@ def test_prompt_create_not_logged(testclient, trusted_client, smtpd):
Check that the user is correctly redirected to the client page after
the registration process.
"""
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
res = testclient.get(
"/oauth/authorize",

View file

@ -3,18 +3,21 @@ import warnings
import pytest
from canaille.app.configuration import ConfigurationException
from canaille.app.configuration import settings_factory
from canaille.app.configuration import validate
from canaille.oidc.oauth import get_issuer
def test_issuer(testclient):
with warnings.catch_warnings(record=True):
testclient.app.config["OIDC"]["JWT"]["ISS"] = "https://anyauth.mydomain.tld"
testclient.app.config["CANAILLE_OIDC"]["JWT"]["ISS"] = (
"https://anyauth.mydomain.tld"
)
testclient.app.config["SERVER_NAME"] = "https://otherauth.mydomain.tld"
with testclient.app.test_request_context("/"):
assert get_issuer() == "https://anyauth.mydomain.tld"
del testclient.app.config["OIDC"]["JWT"]["ISS"]
testclient.app.config["CANAILLE_OIDC"]["JWT"]["ISS"] = None
with testclient.app.test_request_context("/"):
assert get_issuer() == "https://otherauth.mydomain.tld"
@ -24,18 +27,24 @@ def test_issuer(testclient):
def test_no_private_key(testclient, configuration):
del configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
del configuration["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"]
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"No private key has been set",
):
validate(configuration)
validate(config_dict)
def test_no_public_key(testclient, configuration):
del configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
del configuration["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
config_obj = settings_factory(configuration)
config_dict = config_obj.model_dump()
with pytest.raises(
ConfigurationException,
match=r"No public key has been set",
):
validate(configuration)
validate(config_dict)

View file

@ -8,10 +8,10 @@ from canaille.app import models
def test_client_registration_with_authentication_static_token(
testclient, backend, client, user
):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
"static-token"
]
@ -70,7 +70,7 @@ def test_client_registration_with_authentication_static_token(
def test_client_registration_with_authentication_no_token(
testclient, backend, client, user
):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
@ -104,7 +104,7 @@ def test_client_registration_with_authentication_no_token(
def test_client_registration_with_authentication_invalid_token(
testclient, backend, client, user
):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
@ -130,7 +130,7 @@ def test_client_registration_with_authentication_invalid_token(
def test_client_registration_with_software_statement(testclient, backend, keypair):
private_key, _ = keypair
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
software_statement_payload = {
"software_id": "4NRB1-0XZABZI9E6-5SM3R",
@ -181,7 +181,7 @@ def test_client_registration_with_software_statement(testclient, backend, keypai
def test_client_registration_without_authentication_ok(testclient, backend):
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
payload = {
"redirect_uris": [

View file

@ -5,10 +5,10 @@ from canaille.app import models
def test_get(testclient, backend, client, user):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
"static-token"
]
@ -51,10 +51,10 @@ def test_get(testclient, backend, client, user):
def test_update(testclient, backend, client, user):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
"static-token"
]
@ -138,10 +138,10 @@ def test_update(testclient, backend, client, user):
def test_delete(testclient, backend, user):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
"static-token"
]
@ -157,10 +157,10 @@ def test_delete(testclient, backend, user):
def test_invalid_client(testclient, backend, user):
assert not testclient.app.config.get("OIDC", {}).get(
assert not testclient.app.config["CANAILLE_OIDC"].get(
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
)
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
"static-token"
]

View file

@ -233,7 +233,7 @@ def test_no_jwt_no_logout(testclient, backend, logged_user, client):
def test_jwt_not_issued_here(testclient, backend, logged_user, client, id_token):
testclient.app.config["OIDC"]["JWT"]["ISS"] = "https://foo.bar"
testclient.app.config["CANAILLE_OIDC"]["JWT"]["ISS"] = "https://foo.bar"
testclient.get(f"/profile/{logged_user.identifier}", status=200)

View file

@ -1,4 +1,4 @@
from canaille.oidc.oauth import DEFAULT_JWT_MAPPING
from canaille.oidc.configuration import JWTSettings
from canaille.oidc.oauth import claims_from_scope
from canaille.oidc.oauth import generate_user_claims
@ -267,7 +267,8 @@ STANDARD_CLAIMS = [
def test_generate_user_standard_claims_with_default_config(testclient, backend, user):
user.preferred_language = "fr"
data = generate_user_claims(user, STANDARD_CLAIMS, DEFAULT_JWT_MAPPING)
default_jwt_mapping = JWTSettings().model_dump()
data = generate_user_claims(user, STANDARD_CLAIMS, default_jwt_mapping)
assert data == {
"address": "1235, somewhere",
@ -283,7 +284,7 @@ def test_generate_user_standard_claims_with_default_config(testclient, backend,
def test_custom_config_format_claim_is_well_formated(testclient, backend, user):
jwt_mapping_config = DEFAULT_JWT_MAPPING.copy()
jwt_mapping_config = JWTSettings().model_dump()
jwt_mapping_config["EMAIL"] = "{{ user.user_name }}@mydomain.tld"
data = generate_user_claims(user, STANDARD_CLAIMS, jwt_mapping_config)
@ -297,6 +298,7 @@ def test_claim_is_omitted_if_empty(testclient, backend, user):
user.emails = []
user.save()
data = generate_user_claims(user, STANDARD_CLAIMS, DEFAULT_JWT_MAPPING)
default_jwt_mapping = JWTSettings().model_dump()
data = generate_user_claims(user, STANDARD_CLAIMS, default_jwt_mapping)
assert "email" not in data

View file

@ -103,6 +103,6 @@ def test_openid_configuration(testclient):
def test_openid_configuration_prompt_value_create(testclient):
testclient.app.config["ENABLE_REGISTRATION"] = True
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
res = testclient.get("/.well-known/openid-configuration", status=200).json
assert "create" in res["prompt_values_supported"]