diff --git a/CHANGES.rst b/CHANGES.rst index e7136903..f19e24de 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,16 +1,17 @@ -All notable changes to this project will be documented in this file. +Unreleased +---------- -The format is based on `Keep a Changelog `_, -and this project adheres to `Semantic Versioning `_. +🚨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 -======= +^^^^^^^ - Use default python logging configuration format. :issue:`188` :pr:`165` - Bump to htmx 1.99.11 :pr:`166` @@ -21,7 +22,7 @@ Changed --------------------- Fixed -===== +^^^^^ - Avoid to fail on imports if ``cryptography`` is missing. @@ -29,12 +30,12 @@ Fixed --------------------- Added -===== +^^^^^ - OIDC `prompt=create` support. :issue:`185` :pr:`164` Fixed -===== +^^^^^ - Correctly set up Client audience during OIDC dynamic registration. - ``post_logout_redirect_uris`` was ignored during OIDC dynamic registration. @@ -44,7 +45,7 @@ Fixed --------------------- Added -===== +^^^^^ - ``THEME`` can be a relative path @@ -52,7 +53,7 @@ Added --------------------- Fixed -===== +^^^^^ - Crash when no ACL were defined - OIDC Userinfo endpoint is also available in POST @@ -62,7 +63,7 @@ Fixed --------------------- Changed -======= +^^^^^^^ - Convert all the png in webp. :pr:`162` - Update to flask 3 :issue:`161` :pr:`163` @@ -71,7 +72,7 @@ Changed --------------------- Fixed -===== +^^^^^ - Handle 4xx and 5xx error codes with htmx. :issue:`171` :pr:`161` @@ -79,7 +80,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - Avoid crashing when LDAP groups references unexisting users. - Password reset and initialization mails were only sent to the @@ -93,19 +94,19 @@ Fixed --------------------- Added -===== +^^^^^ - Refresh token grant supports other client authentication methods. :pr:`157` - Implement a SQLAlchemy backend. :issue:`30` :pr:`158` Changed -======= +^^^^^^^ - Model attributes cardinality is closer to SCIM model. :pr:`155` - Bump to htmx 1.9.9 :pr:`159` Fixed -===== +^^^^^ - Disable HTMX boosting during the OIDC dance. :pr:`160` @@ -113,13 +114,13 @@ Fixed --------------------- Fixed -===== +^^^^^ - Canaille installations without account lockabilty could not delete users. :pr:`153` Added -===== +^^^^^ - If users register or authenticate during a OAuth Authorization phase, they get redirected back to that page afterwards. @@ -133,12 +134,12 @@ Added --------------------- Fixed -===== +^^^^^ - OIDC jwks endpoint do not return empty kid claim Added -===== +^^^^^ - Documentation details on the canaille models. @@ -146,7 +147,7 @@ Added --------------------- Added -===== +^^^^^ - Additional inmemory backend :issue:`30` :pr:`149` - Installation extras :issue:`167` :pr:`150` @@ -155,7 +156,7 @@ Added --------------------- Added -===== +^^^^^ - Configuration option to disable the forced usage of OIDC nonce :pr:`143` - Validate phone numbers with a regex :pr:`146` @@ -163,12 +164,12 @@ Added - Account registration :issue:`55` :pr:`133` :pr:`148` Fixed -===== +^^^^^ - The `check` command uses the default configuration values. Changed -======= +^^^^^^^ - Modals do not need use javascript at the moment. :issue:`158` :pr:`144` @@ -179,12 +180,12 @@ Changed Check the new format with ``git diff 0.0.29 0.0.30 canaille/conf/config.sample.toml`` Added -===== +^^^^^ - Configuration option to disable javascript :pr:`141` Changed -======= +^^^^^^^ - Configuration ``USER_FILTER`` is parsed with jinja. - Configuration use ``PRIVATE_KEY_FILE`` instead of ``PRIVATE_KEY`` and ``PUBLIC_KEY_FILE`` instead of ``PUBLIC_KEY`` @@ -193,7 +194,7 @@ Changed --------------------- Fixed -===== +^^^^^ - Disabled HTMX boosting on OIDC forms to avoid errors. @@ -201,7 +202,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - A template variable was misnamed. @@ -212,7 +213,7 @@ Fixed Check the new format with ``git diff 0.0.26 0.0.27 canaille/conf/config.sample.toml`` Added -===== +^^^^^ - Configuration entries can be loaded from files if the entry key has a *_FILE* suffix and the entry value is the path to the file. :issue:`134` :pr:`134` @@ -220,17 +221,17 @@ Added - Pages are boosted with HTMX :issue:`144` :issue:`145` :pr:`137` Changed -======= +^^^^^^^ - Bump to jquery 3.7.0 :pr:`138` Fixed -===== +^^^^^ - Profile edition when the user RDN was not ``uid`` :issue:`148` :pr:`139` Removed -======= +^^^^^^^ - Stop support for python 3.7 :pr:`131` @@ -238,14 +239,14 @@ Removed --------------------- Added -===== +^^^^^ - Implemented account expiration based on OpenLDAP ppolicy overlay. Needs OpenLDAP 2.5+ :issue:`13` :pr:`118` - Timezone configuration entry. :issue:`137` :pr:`130` Fixed -===== +^^^^^ - Avoid setting ``None`` in JWT claims when they have no value. - Display password recovery button on OIDC login page. :pr:`129` @@ -257,7 +258,7 @@ Fixed Check the new format with ``git diff 0.0.25 0.0.24 canaille/conf/config.sample.toml`` Changed -======= +^^^^^^^ - Renamed user model attributes to match SCIM naming convention. :pr:`123` - Moved OIDC related configuration entries in ``OIDC`` @@ -267,7 +268,7 @@ Changed - Bumped to htmx 1.9.2 :pr:`127` Fixed -===== +^^^^^ - ``OIDC.JWT.MAPPING`` configuration entry is really optional now. - Fixed empty model attributes registration :pr:`125` @@ -277,7 +278,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - Fixed avatar update. :pr:`122` @@ -285,20 +286,20 @@ Fixed --------------------- Added -===== +^^^^^ - Organization field. :pr:`116` - ETag and Last-Modified headers on user photos. :pr:`116` - Dynamic form validation :pr:`120` Changed -======= +^^^^^^^ - UX rework. Submenu addition. :pr:`114` - Properly handle LDAP date timezones. :pr:`117` Fixed -===== +^^^^^ - CSRF protection on every forms. :pr:`119` @@ -306,14 +307,14 @@ Fixed --------------------- Fixed -===== +^^^^^ - faker is not imported anymore when the `clean` command is called. [0.0.21] - 2023-03-12 --------------------- Added -===== +^^^^^ - Display TOS and policy URI on the consent list page. :pr:`102` - Admin token deletion :pr:`100` :pr:`101` @@ -329,13 +330,13 @@ Added - Title edition support :pr:`113` Fixed -===== +^^^^^ - Client deletion also deletes related Consent, Token and AuthorizationCode objects. :issue:`126` :pr:`98` Changed -======= +^^^^^^^ - Removed datatables. @@ -343,7 +344,7 @@ Changed --------------------- Added -===== +^^^^^ - Spanish translation. :pr:`85` :pr:`88` - Dedicated connectivity test email :pr:`89` @@ -352,7 +353,7 @@ Added - Update to datatables 1.13.1 :pr:`90` Fixed -===== +^^^^^ - Fix typos and grammar errors. :pr:`84` - Fix wording and punctuations. :pr:`86` @@ -364,7 +365,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - Ensures the token `expires_in` claim and the `access_token` `exp` claim have the same value. :pr:`83` @@ -373,7 +374,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - OIDC end_session was not returning the ``state`` parameter in the ``post_logout_redirect_uri`` :pr:`82` @@ -382,7 +383,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - Fixed group deletion button. :pr:`80` - Fixed post requests in oidc clients views. :pr:`81` @@ -391,7 +392,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - Fixed LDAP operational attributes handling. @@ -399,7 +400,7 @@ Fixed --------------------- Added -===== +^^^^^ - User can chose their favourite display name. :pr:`77` - Bumped to authlib 1.2. :pr:`78` @@ -411,14 +412,14 @@ Added --------------------- Fixed -===== +^^^^^ - Fixed translation mo files packaging. [0.0.13] - 2022-11-21 --------------------- Fixed -===== +^^^^^ - Fixed a bug on the contacts field in the admin client form following the LDAP schema update of 0.0.12 @@ -432,14 +433,14 @@ Fixed - Fixed client preconsent disabling. :pr:`72` Added -===== +^^^^^ - Python 3.11 support. :pr:`61` - apparmor slapd configuration instructions in CONTRIBUTING.rst :pr:`66` - ``preferredLanguage`` attribute support. :pr:`75` Changed -======= +^^^^^^^ - Replaced the use of the deprecated `FLASK_ENV` environment variable by `FLASK_DEBUG`. @@ -453,7 +454,7 @@ Changed --------------------- Added -===== +^^^^^ - Basic WebFinger endpoint. :pr:`59` - Bumped to FomanticUI 2.9.0 00ffffee @@ -463,12 +464,12 @@ Added --------------------- Added -===== +^^^^^ - Default theme has a dark variant. :pr:`57` Fixed -===== +^^^^^ - Fixed missing ``canaille`` binary. :pr:`58` @@ -476,7 +477,7 @@ Fixed --------------------- Fixed -===== +^^^^^ - Online demo. :pr:`55` - The consent page was displaying scopes not supported by clients. :pr:`56` @@ -486,14 +487,14 @@ Fixed -------------------- Added -===== +^^^^^ - ``DISABLE_PASSWORD_RESET`` configuration option to disable password recovery. :pr:`46` - ``edit_self`` ACL permission to control user self edition. :pr:`47` - Implemented RP-initiated logout :pr:`54` Changed -======= +^^^^^^^ - Bumped to authlib 1 :pr:`48` - documentation improvements :pr:`50` @@ -501,7 +502,7 @@ Changed - additional nonce tests :pr:`52` Fixed -===== +^^^^^ - ``HIDE_INVALID_LOGIN`` behavior and default value. - mo files are not versionned anymore :pr:`49` :pr:`53` @@ -509,7 +510,7 @@ Fixed -------------------- Fixed -===== +^^^^^ - Fixed dependencies @@ -517,7 +518,7 @@ Fixed -------------------- Fixed -===== +^^^^^ - Fixed spaces and escaped special char in ldap cn/dn :pr:`43` @@ -525,12 +526,12 @@ Fixed -------------------- Changed -======= +^^^^^^^ - Access token are JWT. :pr:`38` Fixed -===== +^^^^^ - Default groups on invitations :pr:`41` - Schemas are shipped within the canaille package :pr:`42` @@ -539,12 +540,12 @@ Fixed -------------------- Changed -======= +^^^^^^^ - LDAP model objects have new identifiers :pr:`37` Fixed -===== +^^^^^ - Admin menu dropdown display :pr:`39` - `GROUP_ID_ATTRIBUTE` configuration typo :pr:`40` @@ -553,7 +554,7 @@ Fixed -------------------- Added -===== +^^^^^ - Client preauthorization :pr:`11` - LDAP permissions check with the check command :pr:`12` @@ -576,7 +577,7 @@ Added - LDAP backend refactoring :pr:`35` Fixed -===== +^^^^^ - Fixed ghost members in a group :pr:`14` - Fixed email sender names :pr:`19` @@ -591,7 +592,7 @@ Fixed -------------------- Added -===== +^^^^^ - Two-steps sign-in :issue:`49` - Tokens can have several audiences. :issue:`62` :pr:`9` @@ -599,7 +600,7 @@ Added - Groups managament. :issue:`12` :pr:`6` Fixed -===== +^^^^^ - Introspection access bugfix. :issue:`63` :pr:`10` - Introspection sub claim. :issue:`64` :pr:`7` @@ -608,7 +609,7 @@ Fixed -------------------- Added -===== +^^^^^ - Login page is responsive. :issue:`1` - Adapt mobile keyboards to login page fields. :issue:`2` @@ -630,13 +631,13 @@ Added - Password initialization mail. :pr:`51` Fixed -===== +^^^^^ - Form translations. :issue:`19` :issue:`23` - Avoid to use Google Fonts. :issue:`21` Removed -======= +^^^^^^^ - 'My tokens' page. :issue:`22` @@ -644,6 +645,6 @@ Removed -------------------- Added -===== +^^^^^ - Initial release. diff --git a/canaille/__init__.py b/canaille/__init__.py index 204b4d61..5226f5e4 100644 --- a/canaille/__init__.py +++ b/canaille/__init__.py @@ -11,7 +11,7 @@ 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 global_processor(): 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) diff --git a/canaille/app/__init__.py b/canaille/app/__init__.py index 3bbc0a69..dc1a3c32 100644 --- a/canaille/app/__init__.py +++ b/canaille/app/__init__.py @@ -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] diff --git a/canaille/app/configuration.py b/canaille/app/configuration.py index 3d08efc6..1bd58c4f 100644 --- a/canaille/app/configuration.py +++ b/canaille/app/configuration.py @@ -2,41 +2,101 @@ 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 ConfigurationException(Exception): - pass +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 ` + - :doc:`Flask-WTF ` + - :doc:`Flask-Babel ` + - :doc:`Authlib ` + """ + + 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 parse_file_keys(config): - """Replaces configuration entries with the '_FILE' suffix with the matching - file content.""" +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)} - SUFFIX = "_FILE" - new_config = {} - for key, value in config.items(): - if isinstance(value, Mapping): - new_config[key] = parse_file_keys(value) + 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) - elif isinstance(key, str) and key.endswith(SUFFIX) and isinstance(value, str): - with open(value) as f: - value = f.read().rstrip("\n") + if "CANAILLE_LDAP" in config or any( + var.startswith("CANAILLE__LDAP__") for var in os.environ + ): + from canaille.backends.ldap.configuration import LDAPSettings - root_key = key[: -len(SUFFIX)] - new_config[root_key] = value + attributes["CANAILLE_LDAP"] = (Optional[LDAPSettings], None) - else: - new_config[key] = value + 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 new_config + return Settings(**config, _secrets_dir=os.environ.get("SECRETS_DIR")) + + +class ConfigurationException(Exception): + pass def toml_content(file_path): @@ -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( diff --git a/canaille/app/flask.py b/canaille/app/flask.py index 2a8d0530..da50534b 100644 --- a/canaille/app/flask.py +++ b/canaille/app/flask.py @@ -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") diff --git a/canaille/app/i18n.py b/canaille/app/i18n.py index e9bd9569..9905e31b 100644 --- a/canaille/app/i18n.py +++ b/canaille/app/i18n.py @@ -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 diff --git a/canaille/app/mails.py b/canaille/app/mails.py index 9bf6a18a..e2e015e1 100644 --- a/canaille/app/mails.py +++ b/canaille/app/mails.py @@ -10,14 +10,9 @@ 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) diff --git a/canaille/app/themes.py b/canaille/app/themes.py index 4f9c5ede..2150a1a3 100644 --- a/canaille/app/themes.py +++ b/canaille/app/themes.py @@ -12,9 +12,10 @@ 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 @@ def setup_themer(app): @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): diff --git a/canaille/backends/__init__.py b/canaille/backends/__init__.py index a7b90d77..90351a44 100644 --- a/canaille/backends/__init__.py +++ b/canaille/backends/__init__.py @@ -82,8 +82,20 @@ def register_models(self): 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) diff --git a/canaille/backends/ldap/backend.py b/canaille/backends/ldap/backend.py index 23bbb3ed..e0add0b1 100644 --- a/canaille/backends/ldap/backend.py +++ b/canaille/backends/ldap/backend.py @@ -17,10 +17,10 @@ @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 @@ def connection(self): 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 @@ def validate(cls, config): 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 @@ def validate(cls, config): 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 @@ def validate(cls, config): @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) diff --git a/canaille/backends/ldap/configuration.py b/canaille/backends/ldap/configuration.py new file mode 100644 index 00000000..22d5f021 --- /dev/null +++ b/canaille/backends/ldap/configuration.py @@ -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.""" diff --git a/canaille/backends/ldap/models.py b/canaille/backends/ldap/models.py index b79c7f61..a1f7612c 100644 --- a/canaille/backends/ldap/models.py +++ b/canaille/backends/ldap/models.py @@ -12,10 +12,6 @@ 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 @@ def has_password(self): 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 @@ def load_permissions(self): 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 @@ def load_permissions(self): 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 @@ def identifier(self): @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] diff --git a/canaille/backends/memory/models.py b/canaille/backends/memory/models.py index 199a263d..bfd4f03d 100644 --- a/canaille/backends/memory/models.py +++ b/canaille/backends/memory/models.py @@ -240,7 +240,7 @@ def load_permissions(self): 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", [])) diff --git a/canaille/backends/models.py b/canaille/backends/models.py index 6a3303de..f1e135f1 100644 --- a/canaille/backends/models.py +++ b/canaille/backends/models.py @@ -57,7 +57,7 @@ def fuzzy(cls, query, attributes=None, **kwargs): @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 diff --git a/canaille/backends/sql/backend.py b/canaille/backends/sql/backend.py index 69da9477..90db36ed 100644 --- a/canaille/backends/sql/backend.py +++ b/canaille/backends/sql/backend.py @@ -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 @@ def install(cls, config): # pragma: no cover 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, ) diff --git a/canaille/backends/sql/configuration.py b/canaille/backends/sql/configuration.py new file mode 100644 index 00000000..8581d7ab --- /dev/null +++ b/canaille/backends/sql/configuration.py @@ -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" + """ diff --git a/canaille/backends/sql/models.py b/canaille/backends/sql/models.py index 580bb271..dba4ebee 100644 --- a/canaille/backends/sql/models.py +++ b/canaille/backends/sql/models.py @@ -179,7 +179,7 @@ def load_permissions(self): 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", [])) diff --git a/canaille/config.sample.toml b/canaille/config.sample.toml index 2ad01d1c..89c9fae5 100644 --- a/canaille/config.sample.toml +++ b/canaille/config.sample.toml @@ -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 diff --git a/canaille/core/configuration.py b/canaille/core/configuration.py new file mode 100644 index 00000000..9e0bc64e --- /dev/null +++ b/canaille/core/configuration.py @@ -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 `_. + """ + + 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 `_ 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"] + """ diff --git a/canaille/core/endpoints/account.py b/canaille/core/endpoints/account.py index 5e61b8d9..815b14f2 100644 --- a/canaille/core/endpoints/account.py +++ b/canaille/core/endpoints/account.py @@ -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 @@ def creation_date(self): 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 diff --git a/canaille/core/endpoints/admin.py b/canaille/core/endpoints/admin.py index 851532d9..3bb6e0c7 100644 --- a/canaille/core/endpoints/admin.py +++ b/canaille/core/endpoints/admin.py @@ -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, ) diff --git a/canaille/core/endpoints/auth.py b/canaille/core/endpoints/auth.py index b1f5bff5..4bae622c 100644 --- a/canaille/core/endpoints/auth.py +++ b/canaille/core/endpoints/auth.py @@ -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//", 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) diff --git a/canaille/core/endpoints/forms.py b/canaille/core/endpoints/forms.py index d6c9005f..04c1bfa3 100644 --- a/canaille/core/endpoints/forms.py +++ b/canaille/core/endpoints/forms.py @@ -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) diff --git a/canaille/core/mails.py b/canaille/core/mails.py index 6331fe5b..b3ac5482 100644 --- a/canaille/core/mails.py +++ b/canaille/core/mails.py @@ -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, diff --git a/canaille/oidc/configuration.py b/canaille/oidc/configuration.py new file mode 100644 index 00000000..525759d8 --- /dev/null +++ b/canaille/oidc/configuration.py @@ -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.""" diff --git a/canaille/oidc/endpoints/oauth.py b/canaille/oidc/endpoints/oauth.py index 10d40057..cff3be25 100644 --- a/canaille/oidc/endpoints/oauth.py +++ b/canaille/oidc/endpoints/oauth.py @@ -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( diff --git a/canaille/oidc/installation.py b/canaille/oidc/installation.py index 39872fee..72754012 100644 --- a/canaille/oidc/installation.py +++ b/canaille/oidc/installation.py @@ -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() diff --git a/canaille/oidc/oauth.py b/canaille/oidc/oauth.py index 66202e4a..0b6b6919 100644 --- a/canaille/oidc/oauth.py +++ b/canaille/oidc/oauth.py @@ -35,23 +35,7 @@ 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 @@ def introspect_token(self, token): 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 @@ def authenticate_token(self, request): 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 @@ def get_server_metadata(self): 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), ], ) diff --git a/demo/conf-docker/canaille-ldap.toml b/demo/conf-docker/canaille-ldap.toml index 1a158e3e..30904ced 100644 --- a/demo/conf-docker/canaille-ldap.toml +++ b/demo/conf-docker/canaille-ldap.toml @@ -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,", ...] -# [REGISTRATION] -# GROUPS=[] -# CAN_EDIT_USERNAME = false diff --git a/demo/conf-docker/canaille-memory.toml b/demo/conf-docker/canaille-memory.toml index c1623a9f..019654a9 100644 --- a/demo/conf-docker/canaille-memory.toml +++ b/demo/conf-docker/canaille-memory.toml @@ -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,", ...] -# [REGISTRATION] -# GROUPS=[] -# CAN_EDIT_USERNAME = false diff --git a/demo/conf-docker/canaille-sql.toml b/demo/conf-docker/canaille-sql.toml index 2d2de7fd..7fdd6d36 100644 --- a/demo/conf-docker/canaille-sql.toml +++ b/demo/conf-docker/canaille-sql.toml @@ -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 - -# 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" +[CANAILLE_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,", ...] -# [REGISTRATION] -# GROUPS=[] -# CAN_EDIT_USERNAME = false diff --git a/demo/conf/canaille-ldap.toml b/demo/conf/canaille-ldap.toml index 67790cbe..00d6dec8 100644 --- a/demo/conf/canaille-ldap.toml +++ b/demo/conf/canaille-ldap.toml @@ -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" diff --git a/demo/conf/canaille-memory.toml b/demo/conf/canaille-memory.toml index cd51fafd..ebd92af5 100644 --- a/demo/conf/canaille-memory.toml +++ b/demo/conf/canaille-memory.toml @@ -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" diff --git a/demo/conf/canaille-sql.toml b/demo/conf/canaille-sql.toml index ab7e4b2e..6ce39b77 100644 --- a/demo/conf/canaille-sql.toml +++ b/demo/conf/canaille-sql.toml @@ -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 - -# 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" +[CANAILLE_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" diff --git a/demo/run.sh b/demo/run.sh index 0c956cf1..dc5c9814 100755 --- a/demo/run.sh +++ b/demo/run.sh @@ -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 diff --git a/doc/changelog.rst b/doc/changelog.rst index 4b475116..2569d171 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,7 +1,54 @@ Roadmap and changelog ##################### -canaille 0 versions -------------------- +Roadmap +******* + +Bêta version +============ + +To go out of the current Alpha version we want to achieve the following tasks: + +- :issue:`Configuration validation using pydantic <138>` + +Stable version +============== + +Before we push Canaille in stable version we want to achieve the following tasks: + +Security +-------- + +- :issue:`Password hashing configuration <175>` +- :issue:`Authentication logging policy <177>` +- :issue:`Intruder lockout <173>` +- :issue:`Password expiry policy <176>` +- :issue:`Password compromission check <179>` +- :issue:`Multi-factor authentication: Email <47>` +- :issue:`Multi-factor authentication: SMS <47>` +- :issue:`Multi-factor authentication: OTP <47>` + +Packaging +--------- + +- :issue:`Nix package <190>` +- :issue:`Docker / OCI package <59>` + +And beyond +========== + +- :issue:`OpenID Connect certification <182>` +- :issue:`SCIM support <116>` + +Release notes +************* + +All notable changes to this project will be documented in there. + +The format is based on `Keep a Changelog `_, +and this project adheres to `Semantic Versioning `_. + +Alpha versions +============== .. include:: ../CHANGES.rst diff --git a/doc/conf.py b/doc/conf.py index b12ff3e2..5582a98a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -34,8 +34,10 @@ def __getattr__(cls, name): "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 @@ def __getattr__(cls, name): "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 @@ def __getattr__(cls, name): 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 diff --git a/doc/configuration.rst b/doc/configuration.rst index 392354da..854c8f3c 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -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 `_ 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 `_. -: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 `_ 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 `_. - -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@` where `HOSTNAME` is the current hostname. +.. literalinclude :: ../canaille/config.sample.toml + :language: toml diff --git a/doc/databases.rst b/doc/databases.rst index 805f0378..7af068f6 100644 --- a/doc/databases.rst +++ b/doc/databases.rst @@ -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 `_, 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. diff --git a/doc/deployment.rst b/doc/deployment.rst index 60bb8a2d..1ef793bb 100644 --- a/doc/deployment.rst +++ b/doc/deployment.rst @@ -153,12 +153,12 @@ Apache RewriteRule "^/.well-know/webfinger" "https://auth.mydomain.tld/.well-known/webfinger" [R,L] -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. diff --git a/doc/troubleshooting.rst b/doc/troubleshooting.rst index 29d7ab8b..40fcd147 100644 --- a/doc/troubleshooting.rst +++ b/doc/troubleshooting.rst @@ -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. diff --git a/poetry.lock b/poetry.lock index 1601eb59..4c9c5f10 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index c86a7ab6..55dfca26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/app/commands/test_check.py b/tests/app/commands/test_check.py index 02a7252c..05d79873 100644 --- a/tests/app/commands/test_check.py +++ b/tests/app/commands/test_check.py @@ -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 diff --git a/tests/app/test_configuration.py b/tests/app/test_configuration.py index 56fcba01..14dcde6b 100644 --- a/tests/app/test_configuration.py +++ b/tests/app/test_configuration.py @@ -5,68 +5,97 @@ 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) diff --git a/tests/app/test_flaskutils.py b/tests/app/test_flaskutils.py index b3e2d61c..98667afc 100644 --- a/tests/app/test_flaskutils.py +++ b/tests/app/test_flaskutils.py @@ -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: diff --git a/tests/app/test_forms.py b/tests/app/test_forms.py index bcff10bf..e4afd25f 100644 --- a/tests/app/test_forms.py +++ b/tests/app/test_forms.py @@ -11,7 +11,7 @@ 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 @@ class Foobar: 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 @@ class Foobar: 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 @@ class Foobar: 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 @@ class Foobar: 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"] diff --git a/tests/app/test_i18n.py b/tests/app/test_i18n.py index 9d47ce39..7185d705 100644 --- a/tests/app/test_i18n.py +++ b/tests/app/test_i18n.py @@ -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" diff --git a/tests/app/test_mails.py b/tests/app/test_mails.py index 9a5818e3..ab5c6e7f 100644 --- a/tests/app/test_mails.py +++ b/tests/app/test_mails.py @@ -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) diff --git a/tests/backends/ldap/fixtures.py b/tests/backends/ldap/fixtures.py index c11c13ce..70212f16 100644 --- a/tests/backends/ldap/fixtures.py +++ b/tests/backends/ldap/fixtures.py @@ -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 diff --git a/tests/backends/ldap/test_configuration.py b/tests/backends/ldap/test_configuration.py index ed187b21..23d59e60 100644 --- a/tests/backends/ldap/test_configuration.py +++ b/tests/backends/ldap/test_configuration.py @@ -3,7 +3,7 @@ @pytest.fixture def configuration(ldap_configuration): - ldap_configuration["BACKENDS"]["LDAP"]["USER_RDN"] = "mail" + ldap_configuration["CANAILLE_LDAP"]["USER_RDN"] = "mail" yield ldap_configuration diff --git a/tests/backends/ldap/test_errors.py b/tests/backends/ldap/test_errors.py index 5af7902c..e585f209 100644 --- a/tests/backends/ldap/test_errors.py +++ b/tests/backends/ldap/test_errors.py @@ -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) diff --git a/tests/backends/ldap/test_install.py b/tests/backends/ldap/test_install.py index 26d00bc1..61ebb3e4 100644 --- a/tests/backends/ldap/test_install.py +++ b/tests/backends/ldap/test_install.py @@ -2,6 +2,7 @@ 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 - - with Backend(configuration).session(): + 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(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 - - with Backend(configuration).session(): + 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(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" - - with Backend(configuration).session(): + 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(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 - - with Backend(configuration).session(): + 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(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) diff --git a/tests/backends/ldap/test_utils.py b/tests/backends/ldap/test_utils.py index e1a96c2b..df11997a 100644 --- a/tests/backends/ldap/test_utils.py +++ b/tests/backends/ldap/test_utils.py @@ -6,6 +6,7 @@ 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 fake_init(*args, **kwarg): 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 fake_init(*args, **kwarg): 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"] diff --git a/tests/backends/sql/fixtures.py b/tests/backends/sql/fixtures.py index 76c6dacb..573575d4 100644 --- a/tests/backends/sql/fixtures.py +++ b/tests/backends/sql/fixtures.py @@ -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 diff --git a/tests/backends/test_backends.py b/tests/backends/test_backends.py index 0bbec40c..3c877568 100644 --- a/tests/backends/test_backends.py +++ b/tests/backends/test_backends.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py index b01a2dc1..67dcc3ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/core/test_account.py b/tests/core/test_account.py index e2f83068..7175f803 100644 --- a/tests/core/test_account.py +++ b/tests/core/test_account.py @@ -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 'invalid' 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): diff --git a/tests/core/test_email_confirmation.py b/tests/core/test_email_confirmation.py index 04fbe5d6..62678b5f 100644 --- a/tests/core/test_email_confirmation.py +++ b/tests/core/test_email_confirmation.py @@ -11,7 +11,7 @@ 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(), diff --git a/tests/core/test_forgotten_password.py b/tests/core/test_forgotten_password.py index c0e8969c..fca293b0 100644 --- a/tests/core/test_forgotten_password.py +++ b/tests/core/test_forgotten_password.py @@ -2,7 +2,7 @@ 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 'i-dont-really-exist' 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) diff --git a/tests/core/test_invitation.py b/tests/core/test_invitation.py index 4ac50d3d..b5768c88 100644 --- a/tests/core/test_invitation.py +++ b/tests/core/test_invitation.py @@ -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 diff --git a/tests/core/test_password_reset.py b/tests/core/test_password_reset.py index bc366f4f..142d9dbd 100644 --- a/tests/core/test_password_reset.py +++ b/tests/core/test_password_reset.py @@ -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") diff --git a/tests/core/test_permissions.py b/tests/core/test_permissions.py index 7bff8b8a..1776081f 100644 --- a/tests/core/test_permissions.py +++ b/tests/core/test_permissions.py @@ -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 diff --git a/tests/core/test_profile_edition.py b/tests/core/test_profile_edition.py index e3408512..59f77cb7 100644 --- a/tests/core/test_profile_edition.py +++ b/tests/core/test_profile_edition.py @@ -7,7 +7,7 @@ @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() diff --git a/tests/core/test_profile_settings.py b/tests/core/test_profile_settings.py index 39f886c2..766e10f9 100644 --- a/tests/core/test_profile_settings.py +++ b/tests/core/test_profile_settings.py @@ -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) diff --git a/tests/core/test_registration.py b/tests/core/test_registration.py index 7e8a701b..67fa3839 100644 --- a/tests/core/test_registration.py +++ b/tests/core/test_registration.py @@ -9,8 +9,8 @@ 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" diff --git a/tests/oidc/commands/test_install.py b/tests/oidc/commands/test_install.py index 7265dd21..9d120ee7 100644 --- a/tests/oidc/commands/test_install.py +++ b/tests/oidc/commands/test_install.py @@ -2,13 +2,13 @@ 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"] diff --git a/tests/oidc/conftest.py b/tests/oidc/conftest.py index 1de83002..5310fb11 100644 --- a/tests/oidc/conftest.py +++ b/tests/oidc/conftest.py @@ -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 diff --git a/tests/oidc/test_account.py b/tests/oidc/test_account.py index ced2a9ce..29685d05 100644 --- a/tests/oidc/test_account.py +++ b/tests/oidc/test_account.py @@ -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" diff --git a/tests/oidc/test_authorization_code_flow.py b/tests/oidc/test_authorization_code_flow.py index 75480a2c..fd1500c8 100644 --- a/tests/oidc/test_authorization_code_flow.py +++ b/tests/oidc/test_authorization_code_flow.py @@ -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( diff --git a/tests/oidc/test_authorization_prompt.py b/tests/oidc/test_authorization_prompt.py index 4805fbd0..4e20ecc9 100644 --- a/tests/oidc/test_authorization_prompt.py +++ b/tests/oidc/test_authorization_prompt.py @@ -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", diff --git a/tests/oidc/test_configuration.py b/tests/oidc/test_configuration.py index d3fdec3a..2e0d7cc9 100644 --- a/tests/oidc/test_configuration.py +++ b/tests/oidc/test_configuration.py @@ -3,18 +3,21 @@ 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) diff --git a/tests/oidc/test_dynamic_client_registration.py b/tests/oidc/test_dynamic_client_registration.py index b5583a7c..9e9d336f 100644 --- a/tests/oidc/test_dynamic_client_registration.py +++ b/tests/oidc/test_dynamic_client_registration.py @@ -8,10 +8,10 @@ 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": [ diff --git a/tests/oidc/test_dynamic_client_registration_management.py b/tests/oidc/test_dynamic_client_registration_management.py index c5a58baa..08594027 100644 --- a/tests/oidc/test_dynamic_client_registration_management.py +++ b/tests/oidc/test_dynamic_client_registration_management.py @@ -5,10 +5,10 @@ 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" ] diff --git a/tests/oidc/test_end_session.py b/tests/oidc/test_end_session.py index 5ea9e3ba..64b551fa 100644 --- a/tests/oidc/test_end_session.py +++ b/tests/oidc/test_end_session.py @@ -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) diff --git a/tests/oidc/test_userinfo.py b/tests/oidc/test_userinfo.py index 857a7535..f7744a89 100644 --- a/tests/oidc/test_userinfo.py +++ b/tests/oidc/test_userinfo.py @@ -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 @@ def test_userinfo(testclient, token, user, foo_group): 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 diff --git a/tests/oidc/test_well_known.py b/tests/oidc/test_well_known.py index cd7fea68..e80b1289 100644 --- a/tests/oidc/test_well_known.py +++ b/tests/oidc/test_well_known.py @@ -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"]