forked from Github-Mirrors/canaille
feat: use pydantic to validate the configuration
This commit is contained in:
parent
731016d7f3
commit
8625318341
76 changed files with 1492 additions and 2124 deletions
|
@ -1,11 +1,14 @@
|
|||
Unreleased
|
||||
----------
|
||||
|
||||
🚨Configuration files must be updated.🚨
|
||||
|
||||
Added
|
||||
^^^^^
|
||||
|
||||
- Add `created` and `last_modified` datetime for all models
|
||||
- Sitemap to the documentation :pr:`169`
|
||||
- Configuration management with pydantic-settings :issue:`138` :pr:`170`
|
||||
|
||||
Changed
|
||||
^^^^^^^
|
||||
|
|
|
@ -11,7 +11,7 @@ csrf = CSRFProtect()
|
|||
|
||||
|
||||
def setup_sentry(app): # pragma: no cover
|
||||
if not app.config.get("SENTRY_DSN"):
|
||||
if not app.config["CANAILLE"]["SENTRY_DSN"]:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
@ -21,12 +21,14 @@ def setup_sentry(app): # pragma: no cover
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
sentry_sdk.init(dsn=app.config["SENTRY_DSN"], integrations=[FlaskIntegration()])
|
||||
sentry_sdk.init(
|
||||
dsn=app.config["CANAILLE"]["SENTRY_DSN"], integrations=[FlaskIntegration()]
|
||||
)
|
||||
return sentry_sdk
|
||||
|
||||
|
||||
def setup_logging(app):
|
||||
conf = app.config.get("LOGGING")
|
||||
conf = app.config["CANAILLE"]["LOGGING"]
|
||||
if conf is None:
|
||||
log_level = "DEBUG" if app.debug else "INFO"
|
||||
dictConfig(
|
||||
|
@ -72,7 +74,7 @@ def setup_blueprints(app):
|
|||
|
||||
app.register_blueprint(canaille.core.endpoints.bp)
|
||||
|
||||
if "OIDC" in app.config:
|
||||
if "CANAILLE_OIDC" in app.config:
|
||||
import canaille.oidc.endpoints
|
||||
|
||||
app.register_blueprint(canaille.oidc.endpoints.bp)
|
||||
|
@ -92,19 +94,24 @@ def setup_flask(app):
|
|||
|
||||
return {
|
||||
"debug": app.debug or app.config.get("TESTING", False),
|
||||
"has_smtp": "SMTP" in app.config,
|
||||
"has_oidc": "OIDC" in app.config,
|
||||
"has_password_recovery": app.config.get("ENABLE_PASSWORD_RECOVERY", True),
|
||||
"has_registration": app.config.get("ENABLE_REGISTRATION", False),
|
||||
"has_smtp": "SMTP" in app.config["CANAILLE"],
|
||||
"has_oidc": "CANAILLE_OIDC" in app.config["CANAILLE"],
|
||||
"has_password_recovery": app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"],
|
||||
"has_registration": app.config["CANAILLE"]["ENABLE_REGISTRATION"],
|
||||
"has_account_lockability": app.backend.get().has_account_lockability(),
|
||||
"logo_url": app.config.get("LOGO"),
|
||||
"favicon_url": app.config.get("FAVICON", app.config.get("LOGO")),
|
||||
"website_name": app.config.get("NAME", "Canaille"),
|
||||
"logo_url": app.config["CANAILLE"]["LOGO"],
|
||||
"favicon_url": app.config["CANAILLE"]["FAVICON"]
|
||||
or app.config["CANAILLE"]["LOGO"],
|
||||
"website_name": app.config["CANAILLE"]["NAME"],
|
||||
"user": current_user(),
|
||||
"menu": True,
|
||||
"is_boosted": request.headers.get("HX-Boosted", False),
|
||||
"has_email_confirmation": app.config.get("EMAIL_CONFIRMATION") is True
|
||||
or (app.config.get("EMAIL_CONFIRMATION") is None and "SMTP" in app.config),
|
||||
"has_email_confirmation": app.config["CANAILLE"]["EMAIL_CONFIRMATION"]
|
||||
is True
|
||||
or (
|
||||
app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None
|
||||
and "SMTP" in app.config["CANAILLE"]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
@ -137,7 +144,7 @@ def create_app(config=None, validate=True, backend=None):
|
|||
setup_themer(app)
|
||||
setup_flask(app)
|
||||
|
||||
if "OIDC" in app.config:
|
||||
if "CANAILLE_OIDC" in app.config:
|
||||
from .oidc.oauth import setup_oauth
|
||||
|
||||
setup_oauth(app)
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -2,43 +2,103 @@ import os
|
|||
import smtplib
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from typing import Optional
|
||||
|
||||
from flask import current_app
|
||||
from pydantic import create_model
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import SettingsConfigDict
|
||||
|
||||
from canaille.app.mails import DEFAULT_SMTP_HOST
|
||||
from canaille.app.mails import DEFAULT_SMTP_PORT
|
||||
from canaille.core.configuration import CoreSettings
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
class RootSettings(BaseSettings):
|
||||
"""The top-level namespace contains holds the configuration settings
|
||||
unrelated to Canaille. The configuration paramateres from the following
|
||||
libraries can be used:
|
||||
|
||||
- :doc:`Flask <flask:config>`
|
||||
- :doc:`Flask-WTF <flask-wtf:config>`
|
||||
- :doc:`Flask-Babel <flask-babel:index>`
|
||||
- :doc:`Authlib <authlib:flask/2/authorization-server>`
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="allow",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=True,
|
||||
env_file=".env",
|
||||
)
|
||||
|
||||
SECRET_KEY: str
|
||||
"""The Flask :external:py:data:`SECRET_KEY` configuration setting.
|
||||
|
||||
You MUST change this.
|
||||
"""
|
||||
|
||||
SERVER_NAME: Optional[str] = None
|
||||
"""The Flask :external:py:data:`SERVER_NAME` configuration setting.
|
||||
|
||||
This sets domain name on which canaille will be served.
|
||||
"""
|
||||
|
||||
PREFERRED_URL_SCHEME: str = "https"
|
||||
"""The Flask :external:py:data:`PREFERRED_URL_SCHEME` configuration
|
||||
setting.
|
||||
|
||||
This sets the url scheme by which canaille will be served.
|
||||
"""
|
||||
|
||||
DEBUG: bool = False
|
||||
"""The Flask :external:py:data:`DEBUG` configuration setting.
|
||||
|
||||
This enables debug options. This is useful for development but
|
||||
should be absolutely avoided in production environments.
|
||||
"""
|
||||
|
||||
|
||||
def settings_factory(config):
|
||||
"""Overly complicated function that pushes the backend specific
|
||||
configuration into CoreSettings, in the purpose break dependency against
|
||||
backends libraries like python-ldap or sqlalchemy."""
|
||||
attributes = {"CANAILLE": (Optional[CoreSettings], None)}
|
||||
|
||||
if "CANAILLE_SQL" in config or any(
|
||||
var.startswith("CANAILLE_SQL__") for var in os.environ
|
||||
):
|
||||
from canaille.backends.sql.configuration import SQLSettings
|
||||
|
||||
attributes["CANAILLE_SQL"] = (Optional[SQLSettings], None)
|
||||
|
||||
if "CANAILLE_LDAP" in config or any(
|
||||
var.startswith("CANAILLE__LDAP__") for var in os.environ
|
||||
):
|
||||
from canaille.backends.ldap.configuration import LDAPSettings
|
||||
|
||||
attributes["CANAILLE_LDAP"] = (Optional[LDAPSettings], None)
|
||||
|
||||
if "CANAILLE_OIDC" in config or any(
|
||||
var.startswith("CANAILLE_OIDC__") for var in os.environ
|
||||
):
|
||||
from canaille.oidc.configuration import OIDCSettings
|
||||
|
||||
attributes["CANAILLE_OIDC"] = (Optional[OIDCSettings], None)
|
||||
|
||||
Settings = create_model(
|
||||
"Settings",
|
||||
__base__=RootSettings,
|
||||
**attributes,
|
||||
)
|
||||
|
||||
return Settings(**config, _secrets_dir=os.environ.get("SECRETS_DIR"))
|
||||
|
||||
|
||||
class ConfigurationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def parse_file_keys(config):
|
||||
"""Replaces configuration entries with the '_FILE' suffix with the matching
|
||||
file content."""
|
||||
|
||||
SUFFIX = "_FILE"
|
||||
new_config = {}
|
||||
for key, value in config.items():
|
||||
if isinstance(value, Mapping):
|
||||
new_config[key] = parse_file_keys(value)
|
||||
|
||||
elif isinstance(key, str) and key.endswith(SUFFIX) and isinstance(value, str):
|
||||
with open(value) as f:
|
||||
value = f.read().rstrip("\n")
|
||||
|
||||
root_key = key[: -len(SUFFIX)]
|
||||
new_config[root_key] = value
|
||||
|
||||
else:
|
||||
new_config[key] = value
|
||||
|
||||
return new_config
|
||||
|
||||
|
||||
def toml_content(file_path):
|
||||
try:
|
||||
if sys.version_info < (3, 11): # pragma: no cover
|
||||
|
@ -55,7 +115,7 @@ def toml_content(file_path):
|
|||
raise Exception("toml library not installed. Cannot load configuration.")
|
||||
|
||||
|
||||
def setup_config(app, config=None, validate_config=True):
|
||||
def setup_config(app, config=None, test_config=True):
|
||||
from canaille.oidc.installation import install
|
||||
|
||||
app.config.from_mapping(
|
||||
|
@ -65,29 +125,22 @@ def setup_config(app, config=None, validate_config=True):
|
|||
"OAUTH2_ACCESS_TOKEN_GENERATOR": "canaille.oidc.oauth.generate_access_token",
|
||||
}
|
||||
)
|
||||
if not config and "CONFIG" in os.environ:
|
||||
config = toml_content(os.environ.get("CONFIG"))
|
||||
|
||||
if config:
|
||||
app.config.from_mapping(parse_file_keys(config))
|
||||
|
||||
elif "CONFIG" in os.environ:
|
||||
app.config.from_mapping(parse_file_keys(toml_content(os.environ["CONFIG"])))
|
||||
|
||||
else:
|
||||
raise Exception(
|
||||
"No configuration file found. "
|
||||
"Either create conf/config.toml or set the 'CONFIG' variable environment."
|
||||
)
|
||||
config_obj = settings_factory(config)
|
||||
app.config.from_mapping(config_obj.model_dump())
|
||||
|
||||
if app.debug:
|
||||
install(app.config, debug=True)
|
||||
|
||||
if validate_config:
|
||||
if test_config:
|
||||
validate(app.config)
|
||||
|
||||
|
||||
def validate(config, validate_remote=False):
|
||||
validate_keypair(config)
|
||||
validate_theme(config)
|
||||
validate_keypair(config.get("CANAILLE_OIDC"))
|
||||
validate_theme(config["CANAILLE"])
|
||||
|
||||
if not validate_remote:
|
||||
return
|
||||
|
@ -95,39 +148,39 @@ def validate(config, validate_remote=False):
|
|||
from canaille.backends import BaseBackend
|
||||
|
||||
BaseBackend.get().validate(config)
|
||||
validate_smtp_configuration(config)
|
||||
validate_smtp_configuration(config["CANAILLE"]["SMTP"])
|
||||
|
||||
|
||||
def validate_keypair(config):
|
||||
if (
|
||||
config.get("OIDC")
|
||||
and config["OIDC"].get("JWT")
|
||||
and not config["OIDC"]["JWT"].get("PUBLIC_KEY")
|
||||
config
|
||||
and config["JWT"]
|
||||
and not config["JWT"]["PUBLIC_KEY"]
|
||||
and not current_app.debug
|
||||
):
|
||||
raise ConfigurationException("No public key has been set")
|
||||
|
||||
if (
|
||||
config.get("OIDC")
|
||||
and config["OIDC"].get("JWT")
|
||||
and not config["OIDC"]["JWT"].get("PRIVATE_KEY")
|
||||
config
|
||||
and config["JWT"]
|
||||
and not config["JWT"]["PRIVATE_KEY"]
|
||||
and not current_app.debug
|
||||
):
|
||||
raise ConfigurationException("No private key has been set")
|
||||
|
||||
|
||||
def validate_smtp_configuration(config):
|
||||
host = config["SMTP"].get("HOST", DEFAULT_SMTP_HOST)
|
||||
port = config["SMTP"].get("PORT", DEFAULT_SMTP_PORT)
|
||||
host = config["HOST"]
|
||||
port = config["PORT"]
|
||||
try:
|
||||
with smtplib.SMTP(host=host, port=port) as smtp:
|
||||
if config["SMTP"].get("TLS"):
|
||||
if config["TLS"]:
|
||||
smtp.starttls()
|
||||
|
||||
if config["SMTP"].get("LOGIN"):
|
||||
if config["LOGIN"]:
|
||||
smtp.login(
|
||||
user=config["SMTP"]["LOGIN"],
|
||||
password=config["SMTP"].get("PASSWORD"),
|
||||
user=config["LOGIN"],
|
||||
password=config["PASSWORD"],
|
||||
)
|
||||
except (socket.gaierror, ConnectionRefusedError) as exc:
|
||||
raise ConfigurationException(
|
||||
|
@ -136,7 +189,7 @@ def validate_smtp_configuration(config):
|
|||
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise ConfigurationException(
|
||||
f'SMTP authentication failed with user \'{config["SMTP"]["LOGIN"]}\''
|
||||
f'SMTP authentication failed with user \'{config["LOGIN"]}\''
|
||||
) from exc
|
||||
|
||||
except smtplib.SMTPNotSupportedError as exc:
|
||||
|
@ -144,7 +197,7 @@ def validate_smtp_configuration(config):
|
|||
|
||||
|
||||
def validate_theme(config):
|
||||
if not config.get("THEME"):
|
||||
if not config or not config["THEME"]:
|
||||
return
|
||||
|
||||
if not os.path.exists(config["THEME"]) and not os.path.exists(
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -10,14 +10,9 @@ from flask import request
|
|||
from canaille.app import get_current_domain
|
||||
from canaille.app import get_current_mail_domain
|
||||
|
||||
DEFAULT_SMTP_HOST = "localhost"
|
||||
DEFAULT_SMTP_PORT = 25
|
||||
DEFAULT_SMTP_TLS = False
|
||||
DEFAULT_SMTP_SSL = False
|
||||
|
||||
|
||||
def logo():
|
||||
logo_url = current_app.config.get("LOGO")
|
||||
logo_url = current_app.config["CANAILLE"]["LOGO"]
|
||||
if not logo_url:
|
||||
return None, None, None
|
||||
|
||||
|
@ -52,8 +47,8 @@ def send_email(subject, recipient, text, html, attachements=None):
|
|||
msg["Subject"] = subject
|
||||
msg["To"] = f"<{recipient}>"
|
||||
|
||||
name = current_app.config.get("NAME", "Canaille")
|
||||
address = current_app.config["SMTP"].get("FROM_ADDR")
|
||||
name = current_app.config["CANAILLE"]["NAME"]
|
||||
address = current_app.config["CANAILLE"]["SMTP"]["FROM_ADDR"]
|
||||
|
||||
if not address:
|
||||
domain = get_current_mail_domain()
|
||||
|
@ -76,19 +71,19 @@ def send_email(subject, recipient, text, html, attachements=None):
|
|||
try:
|
||||
connection_func = (
|
||||
smtplib.SMTP_SSL
|
||||
if current_app.config["SMTP"].get("SSL", DEFAULT_SMTP_SSL)
|
||||
if current_app.config["CANAILLE"]["SMTP"]["SSL"]
|
||||
else smtplib.SMTP
|
||||
)
|
||||
with connection_func(
|
||||
host=current_app.config["SMTP"].get("HOST", DEFAULT_SMTP_HOST),
|
||||
port=current_app.config["SMTP"].get("PORT", DEFAULT_SMTP_PORT),
|
||||
host=current_app.config["CANAILLE"]["SMTP"]["HOST"],
|
||||
port=current_app.config["CANAILLE"]["SMTP"]["PORT"],
|
||||
) as smtp:
|
||||
if current_app.config["SMTP"].get("TLS", DEFAULT_SMTP_TLS):
|
||||
if current_app.config["CANAILLE"]["SMTP"]["TLS"]:
|
||||
smtp.starttls()
|
||||
if current_app.config["SMTP"].get("LOGIN"):
|
||||
if current_app.config["CANAILLE"]["SMTP"]["LOGIN"]:
|
||||
smtp.login(
|
||||
user=current_app.config["SMTP"].get("LOGIN"),
|
||||
password=current_app.config["SMTP"].get("PASSWORD"),
|
||||
user=current_app.config["CANAILLE"]["SMTP"]["LOGIN"],
|
||||
password=current_app.config["CANAILLE"]["SMTP"]["PASSWORD"],
|
||||
)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
|
|
@ -12,9 +12,10 @@ if flask_themer:
|
|||
render_template = flask_themer.render_template
|
||||
|
||||
def setup_themer(app):
|
||||
theme_config = app.config["CANAILLE"]["THEME"]
|
||||
additional_themes_dir = (
|
||||
os.path.abspath(os.path.dirname(app.config["THEME"]))
|
||||
if app.config.get("THEME") and os.path.exists(app.config["THEME"])
|
||||
os.path.abspath(os.path.dirname(theme_config))
|
||||
if theme_config and os.path.exists(theme_config)
|
||||
else None
|
||||
)
|
||||
themer = flask_themer.Themer(
|
||||
|
@ -26,8 +27,8 @@ if flask_themer:
|
|||
|
||||
@themer.current_theme_loader
|
||||
def get_current_theme():
|
||||
# if config['THEME'] may be a theme name or an absolute path
|
||||
return app.config.get("THEME", "default").split("/")[-1]
|
||||
# if config['THEME'] may be a theme name or a path
|
||||
return app.config["CANAILLE"]["THEME"].split("/")[-1]
|
||||
|
||||
@app.errorhandler(400)
|
||||
def bad_request(error):
|
||||
|
|
|
@ -82,8 +82,20 @@ class BaseBackend:
|
|||
|
||||
def setup_backend(app, backend=None):
|
||||
if not backend:
|
||||
backend_names = list(app.config.get("BACKENDS", {"memory": {}}).keys())
|
||||
backend_name = backend_names[0].lower()
|
||||
prefix = "CANAILLE_"
|
||||
available_backends_names = [
|
||||
f"{prefix}{name}".upper() for name in available_backends()
|
||||
]
|
||||
configured_backend_names = [
|
||||
key[len(prefix) :]
|
||||
for key in app.config.keys()
|
||||
if key in available_backends_names
|
||||
]
|
||||
backend_name = (
|
||||
configured_backend_names[0].lower()
|
||||
if configured_backend_names
|
||||
else "memory"
|
||||
)
|
||||
module = importlib.import_module(f"canaille.backends.{backend_name}.backend")
|
||||
backend_class = getattr(module, "Backend")
|
||||
backend = backend_class(app.config)
|
||||
|
|
|
@ -17,10 +17,10 @@ from .utils import listify
|
|||
|
||||
@contextmanager
|
||||
def ldap_connection(config):
|
||||
conn = ldap.initialize(config["BACKENDS"]["LDAP"]["URI"])
|
||||
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, config["BACKENDS"]["LDAP"].get("TIMEOUT"))
|
||||
conn = ldap.initialize(config["CANAILLE_LDAP"]["URI"])
|
||||
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, config["CANAILLE_LDAP"]["TIMEOUT"])
|
||||
conn.simple_bind_s(
|
||||
config["BACKENDS"]["LDAP"]["BIND_DN"], config["BACKENDS"]["LDAP"]["BIND_PW"]
|
||||
config["CANAILLE_LDAP"]["BIND_DN"], config["CANAILLE_LDAP"]["BIND_PW"]
|
||||
)
|
||||
|
||||
try:
|
||||
|
@ -44,7 +44,7 @@ def install_schema(config, schema_path):
|
|||
|
||||
except ldap.INSUFFICIENT_ACCESS as exc:
|
||||
raise InstallationException(
|
||||
f"The user '{config['BACKENDS']['LDAP']['BIND_DN']}' has insufficient permissions to install LDAP schemas."
|
||||
f"The user '{config['CANAILLE_LDAP']['BIND_DN']}' has insufficient permissions to install LDAP schemas."
|
||||
) from exc
|
||||
|
||||
|
||||
|
@ -80,26 +80,26 @@ class Backend(BaseBackend):
|
|||
return self._connection
|
||||
|
||||
try:
|
||||
self._connection = ldap.initialize(self.config["BACKENDS"]["LDAP"]["URI"])
|
||||
self._connection = ldap.initialize(self.config["CANAILLE_LDAP"]["URI"])
|
||||
self._connection.set_option(
|
||||
ldap.OPT_NETWORK_TIMEOUT,
|
||||
self.config["BACKENDS"]["LDAP"].get("TIMEOUT"),
|
||||
self.config["CANAILLE_LDAP"]["TIMEOUT"],
|
||||
)
|
||||
self._connection.simple_bind_s(
|
||||
self.config["BACKENDS"]["LDAP"]["BIND_DN"],
|
||||
self.config["BACKENDS"]["LDAP"]["BIND_PW"],
|
||||
self.config["CANAILLE_LDAP"]["BIND_DN"],
|
||||
self.config["CANAILLE_LDAP"]["BIND_PW"],
|
||||
)
|
||||
|
||||
except ldap.SERVER_DOWN as exc:
|
||||
message = _("Could not connect to the LDAP server '{uri}'").format(
|
||||
uri=self.config["BACKENDS"]["LDAP"]["URI"]
|
||||
uri=self.config["CANAILLE_LDAP"]["URI"]
|
||||
)
|
||||
logging.error(message)
|
||||
raise ConfigurationException(message) from exc
|
||||
|
||||
except ldap.INVALID_CREDENTIALS as exc:
|
||||
message = _("LDAP authentication failed with user '{user}'").format(
|
||||
user=self.config["BACKENDS"]["LDAP"]["BIND_DN"]
|
||||
user=self.config["CANAILLE_LDAP"]["BIND_DN"]
|
||||
)
|
||||
logging.error(message)
|
||||
raise ConfigurationException(message) from exc
|
||||
|
@ -129,8 +129,8 @@ class Backend(BaseBackend):
|
|||
|
||||
except ldap.INSUFFICIENT_ACCESS as exc:
|
||||
raise ConfigurationException(
|
||||
f'LDAP user \'{config["BACKENDS"]["LDAP"]["BIND_DN"]}\' cannot create '
|
||||
f'users at \'{config["BACKENDS"]["LDAP"]["USER_BASE"]}\''
|
||||
f'LDAP user \'{config["CANAILLE_LDAP"]["BIND_DN"]}\' cannot create '
|
||||
f'users at \'{config["CANAILLE_LDAP"]["USER_BASE"]}\''
|
||||
) from exc
|
||||
|
||||
try:
|
||||
|
@ -154,8 +154,8 @@ class Backend(BaseBackend):
|
|||
|
||||
except ldap.INSUFFICIENT_ACCESS as exc:
|
||||
raise ConfigurationException(
|
||||
f'LDAP user \'{config["BACKENDS"]["LDAP"]["BIND_DN"]}\' cannot create '
|
||||
f'groups at \'{config["BACKENDS"]["LDAP"]["GROUP_BASE"]}\''
|
||||
f'LDAP user \'{config["CANAILLE_LDAP"]["BIND_DN"]}\' cannot create '
|
||||
f'groups at \'{config["CANAILLE_LDAP"]["GROUP_BASE"]}\''
|
||||
) from exc
|
||||
|
||||
finally:
|
||||
|
@ -163,9 +163,7 @@ class Backend(BaseBackend):
|
|||
|
||||
@classmethod
|
||||
def login_placeholder(cls):
|
||||
user_filter = current_app.config["BACKENDS"]["LDAP"].get(
|
||||
"USER_FILTER", models.User.DEFAULT_FILTER
|
||||
)
|
||||
user_filter = current_app.config["CANAILLE_LDAP"]["USER_FILTER"]
|
||||
placeholders = []
|
||||
|
||||
if "cn={{login" in user_filter.replace(" ", ""):
|
||||
|
@ -193,30 +191,20 @@ def setup_ldap_models(config):
|
|||
|
||||
from .ldapobject import LDAPObject
|
||||
|
||||
LDAPObject.root_dn = config["BACKENDS"]["LDAP"]["ROOT_DN"]
|
||||
LDAPObject.root_dn = config["CANAILLE_LDAP"]["ROOT_DN"]
|
||||
|
||||
user_base = config["BACKENDS"]["LDAP"]["USER_BASE"].replace(
|
||||
f',{config["BACKENDS"]["LDAP"]["ROOT_DN"]}', ""
|
||||
user_base = config["CANAILLE_LDAP"]["USER_BASE"].replace(
|
||||
f',{config["CANAILLE_LDAP"]["ROOT_DN"]}', ""
|
||||
)
|
||||
models.User.base = user_base
|
||||
models.User.rdn_attribute = config["BACKENDS"]["LDAP"].get(
|
||||
"USER_RDN", models.User.DEFAULT_RDN
|
||||
)
|
||||
object_class = config["BACKENDS"]["LDAP"].get(
|
||||
"USER_CLASS", models.User.DEFAULT_OBJECT_CLASS
|
||||
)
|
||||
models.User.rdn_attribute = config["CANAILLE_LDAP"]["USER_RDN"]
|
||||
object_class = config["CANAILLE_LDAP"]["USER_CLASS"]
|
||||
models.User.ldap_object_class = listify(object_class)
|
||||
|
||||
group_base = (
|
||||
config["BACKENDS"]["LDAP"]
|
||||
.get("GROUP_BASE", "")
|
||||
.replace(f',{config["BACKENDS"]["LDAP"]["ROOT_DN"]}', "")
|
||||
group_base = config["CANAILLE_LDAP"]["GROUP_BASE"].replace(
|
||||
f',{config["CANAILLE_LDAP"]["ROOT_DN"]}', ""
|
||||
)
|
||||
models.Group.base = group_base or None
|
||||
models.Group.rdn_attribute = config["BACKENDS"]["LDAP"].get(
|
||||
"GROUP_RDN", models.Group.DEFAULT_RDN
|
||||
)
|
||||
object_class = config["BACKENDS"]["LDAP"].get(
|
||||
"GROUP_CLASS", models.Group.DEFAULT_OBJECT_CLASS
|
||||
)
|
||||
models.Group.rdn_attribute = config["CANAILLE_LDAP"]["GROUP_RDN"]
|
||||
object_class = config["CANAILLE_LDAP"]["GROUP_CLASS"]
|
||||
models.Group.ldap_object_class = listify(object_class)
|
||||
|
|
58
canaille/backends/ldap/configuration.py
Normal file
58
canaille/backends/ldap/configuration.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LDAPSettings(BaseModel):
|
||||
"""Settings related to the LDAP backend.
|
||||
|
||||
Belong in the ``CANAILLE_LDAP`` namespace.
|
||||
"""
|
||||
|
||||
URI: str = "ldap://localhost"
|
||||
"""The LDAP server URI."""
|
||||
|
||||
ROOT_DN: str = "dc=mydomain,dc=tld"
|
||||
"""The LDAP root DN."""
|
||||
|
||||
BIND_DN: str = "cn=admin,dc=mydomain,dc=tld"
|
||||
"""The LDAP bind DN."""
|
||||
|
||||
BIND_PW: str = "admin"
|
||||
"""The LDAP bind password."""
|
||||
|
||||
TIMEOUT: float = 0.0
|
||||
"""The LDAP connection timeout."""
|
||||
|
||||
USER_BASE: str
|
||||
"""The LDAP node under which users will be looked for and saved.
|
||||
|
||||
For instance `ou=users,dc=mydomain,dc=tld`.
|
||||
"""
|
||||
|
||||
USER_CLASS: str = "inetOrgPerson"
|
||||
"""The object class to use for creating new users."""
|
||||
|
||||
USER_RDN: str = "uid"
|
||||
"""The attribute to identify an object in the User DN."""
|
||||
|
||||
USER_FILTER: str = "member={user.id}"
|
||||
"""Filter to match users on sign in.
|
||||
|
||||
For instance ``(|(uid={{ login }})(mail={{ login }}))``.
|
||||
Jinja syntax is supported and a ``login`` variable is available,
|
||||
containing the value passed in the login field.
|
||||
"""
|
||||
|
||||
GROUP_BASE: str
|
||||
"""The LDAP node under which groups will be looked for and saved.
|
||||
|
||||
For instance `"ou=groups,dc=mydomain,dc=tld"`.
|
||||
"""
|
||||
|
||||
GROUP_CLASS: str = "groupOfNames"
|
||||
"""The object class to use for creating new groups."""
|
||||
|
||||
GROUP_RDN: str = "cn"
|
||||
"""The attribute to identify an object in the Group DN."""
|
||||
|
||||
GROUP_NAME_ATTRIBUTE: str = "cn"
|
||||
"""The attribute to use to identify a group."""
|
|
@ -12,10 +12,6 @@ from .ldapobject import LDAPObject
|
|||
|
||||
|
||||
class User(canaille.core.models.User, LDAPObject):
|
||||
DEFAULT_OBJECT_CLASS = "inetOrgPerson"
|
||||
DEFAULT_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
|
||||
DEFAULT_RDN = "cn"
|
||||
|
||||
attribute_map = {
|
||||
"id": "dn",
|
||||
"created": "createTimestamp",
|
||||
|
@ -46,9 +42,7 @@ class User(canaille.core.models.User, LDAPObject):
|
|||
|
||||
@classmethod
|
||||
def get_from_login(cls, login=None, **kwargs):
|
||||
raw_filter = current_app.config["BACKENDS"]["LDAP"].get(
|
||||
"USER_FILTER", User.DEFAULT_FILTER
|
||||
)
|
||||
raw_filter = current_app.config["CANAILLE_LDAP"]["USER_FILTER"]
|
||||
filter = (
|
||||
(
|
||||
current_app.jinja_env.from_string(raw_filter).render(
|
||||
|
@ -98,11 +92,11 @@ class User(canaille.core.models.User, LDAPObject):
|
|||
return bool(self.password)
|
||||
|
||||
def check_password(self, password):
|
||||
conn = ldap.initialize(current_app.config["BACKENDS"]["LDAP"]["URI"])
|
||||
conn = ldap.initialize(current_app.config["CANAILLE_LDAP"]["URI"])
|
||||
|
||||
conn.set_option(
|
||||
ldap.OPT_NETWORK_TIMEOUT,
|
||||
current_app.config["BACKENDS"]["LDAP"].get("TIMEOUT"),
|
||||
current_app.config["CANAILLE_LDAP"]["TIMEOUT"],
|
||||
)
|
||||
|
||||
message = None
|
||||
|
@ -180,7 +174,7 @@ class User(canaille.core.models.User, LDAPObject):
|
|||
self.read = set()
|
||||
self.write = set()
|
||||
|
||||
for access_group_name, details in current_app.config.get("ACL", {}).items():
|
||||
for access_group_name, details in current_app.config["CANAILLE"]["ACL"].items():
|
||||
filter_ = self.acl_filter_to_ldap_filter(details.get("FILTER"))
|
||||
if not filter_ or (
|
||||
self.id and conn.search_s(self.id, ldap.SCOPE_SUBTREE, filter_)
|
||||
|
@ -191,11 +185,6 @@ class User(canaille.core.models.User, LDAPObject):
|
|||
|
||||
|
||||
class Group(canaille.core.models.Group, LDAPObject):
|
||||
DEFAULT_OBJECT_CLASS = "groupOfNames"
|
||||
DEFAULT_RDN = "cn"
|
||||
DEFAULT_NAME_ATTRIBUTE = "cn"
|
||||
DEFAULT_USER_FILTER = "member={user.id}"
|
||||
|
||||
attribute_map = {
|
||||
"id": "dn",
|
||||
"created": "createTimestamp",
|
||||
|
@ -211,9 +200,7 @@ class Group(canaille.core.models.Group, LDAPObject):
|
|||
|
||||
@property
|
||||
def display_name(self):
|
||||
attribute = current_app.config["BACKENDS"]["LDAP"].get(
|
||||
"GROUP_NAME_ATTRIBUTE", Group.DEFAULT_NAME_ATTRIBUTE
|
||||
)
|
||||
attribute = current_app.config["CANAILLE_LDAP"]["GROUP_NAME_ATTRIBUTE"]
|
||||
return getattr(self, attribute)[0]
|
||||
|
||||
|
||||
|
|
|
@ -240,7 +240,7 @@ class User(canaille.core.models.User, MemoryModel):
|
|||
self.permissions = set()
|
||||
self.read = set()
|
||||
self.write = set()
|
||||
for access_group_name, details in current_app.config.get("ACL", {}).items():
|
||||
for access_group_name, details in current_app.config["CANAILLE"]["ACL"].items():
|
||||
if self.match_filter(details.get("FILTER")):
|
||||
self.permissions |= set(details.get("PERMISSIONS", []))
|
||||
self.read |= set(details.get("READ", []))
|
||||
|
|
|
@ -57,7 +57,7 @@ class Model:
|
|||
@classmethod
|
||||
def get(cls, identifier=None, **kwargs):
|
||||
"""Works like :meth:`~canaille.backends.models.query` but return only
|
||||
one element or :const:`None` if no item is matching."""
|
||||
one element or :py:data:`None` if no item is matching."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
|
|
|
@ -21,7 +21,7 @@ class Backend(BaseBackend):
|
|||
@classmethod
|
||||
def install(cls, config): # pragma: no cover
|
||||
engine = create_engine(
|
||||
config["BACKENDS"]["SQL"]["SQL_DATABASE_URI"],
|
||||
config["CANAILLE_SQL"]["DATABASE_URI"],
|
||||
echo=False,
|
||||
future=True,
|
||||
)
|
||||
|
@ -30,7 +30,7 @@ class Backend(BaseBackend):
|
|||
def setup(self, init=False):
|
||||
if not self.db_session:
|
||||
self.db_session = db_session(
|
||||
self.config["BACKENDS"]["SQL"]["SQL_DATABASE_URI"],
|
||||
self.config["CANAILLE_SQL"]["DATABASE_URI"],
|
||||
init=init,
|
||||
)
|
||||
|
||||
|
|
15
canaille/backends/sql/configuration.py
Normal file
15
canaille/backends/sql/configuration.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SQLSettings(BaseModel):
|
||||
"""Settings related to the SQL backend.
|
||||
|
||||
Belong in the ``CANAILLE_SQL`` namespace.
|
||||
"""
|
||||
|
||||
DATABASE_URI: str
|
||||
"""The SQL server URI.
|
||||
For example::
|
||||
|
||||
DATABASE_URI = "postgresql://user:password@localhost/database_name"
|
||||
"""
|
|
@ -179,7 +179,7 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
|
|||
self.permissions = set()
|
||||
self.read = set()
|
||||
self.write = set()
|
||||
for access_group_name, details in current_app.config.get("ACL", {}).items():
|
||||
for access_group_name, details in current_app.config["CANAILLE"]["ACL"].items():
|
||||
if self.match_filter(details.get("FILTER")):
|
||||
self.permissions |= set(details.get("PERMISSIONS", []))
|
||||
self.read |= set(details.get("READ", []))
|
||||
|
|
|
@ -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
|
||||
|
|
310
canaille/core/configuration.py
Normal file
310
canaille/core/configuration.py
Normal file
|
@ -0,0 +1,310 @@
|
|||
from enum import Enum
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ValidationInfo
|
||||
from pydantic import field_validator
|
||||
|
||||
|
||||
class SMTPSettings(BaseModel):
|
||||
"""The SMTP configuration. Belong in the ``CANAILLE.SMTP`` namespace. If
|
||||
unset, mail related features will be disabled, such as mail verification or
|
||||
password recovery emails.
|
||||
|
||||
By default, Canaille will try to send mails from localhost without
|
||||
authentication.
|
||||
"""
|
||||
|
||||
HOST: Optional[str] = "localhost"
|
||||
"""The SMTP host."""
|
||||
|
||||
PORT: Optional[int] = 25
|
||||
"""The SMTP port."""
|
||||
|
||||
TLS: Optional[bool] = False
|
||||
"""Wether to use TLS to connect to the SMTP server."""
|
||||
|
||||
SSL: Optional[bool] = False
|
||||
"""Wether to use SSL to connect to the SMTP server."""
|
||||
|
||||
LOGIN: Optional[str] = None
|
||||
"""The SMTP login."""
|
||||
|
||||
PASSWORD: Optional[str] = None
|
||||
"""The SMTP password."""
|
||||
|
||||
FROM_ADDR: Optional[str] = None
|
||||
"""The sender for Canaille mails.
|
||||
|
||||
Some mail provider might require a valid sender address.
|
||||
"""
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
"""The permissions that can be assigned to users."""
|
||||
|
||||
EDIT_SELF = "edit_self"
|
||||
"""Allows users to edit their own profile."""
|
||||
|
||||
USE_OIDC = "use_oidc"
|
||||
"""Allows OpenID Connect authentication."""
|
||||
|
||||
MANAGE_OIDC = "manage_oidc"
|
||||
"""Allows OpenID Connect client managements."""
|
||||
|
||||
MANAGE_USERS = "manage_users"
|
||||
"""Allows other users management."""
|
||||
|
||||
MANAGE_GROUPS = "manage_groups"
|
||||
"""Allows group edition and creation."""
|
||||
|
||||
DELETE_ACCOUNT = "delete_account"
|
||||
"""Allows users to delete their account.
|
||||
|
||||
If used with :enum:member:`~canaille.core.configuration.Permission.MANAGE_USERS`, users can delete any account.
|
||||
"""
|
||||
|
||||
IMPERSONATE_USERS = "impersonate_users"
|
||||
"""Allows users to take the identity of another user."""
|
||||
|
||||
|
||||
class ACLSettings(BaseModel):
|
||||
"""Access Control List settings. Belong in the ``CANAILLE.ACL`` namespace.
|
||||
|
||||
You can define access controls that define what users can do on canaille
|
||||
An access control consists in a :attr:`FILTER` to match users, a list of :attr:`PERMISSIONS`
|
||||
matched users will be able to perform, and fields users will be able
|
||||
to :attr:`READ` and :attr:`WRITE`. Users matching several filters will cumulate permissions.
|
||||
"""
|
||||
|
||||
PERMISSIONS: List[Permission] = [Permission.EDIT_SELF, Permission.USE_OIDC]
|
||||
"""A list of :class:`Permission` users in the access control will be able
|
||||
to manage. For example::
|
||||
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
"manage_groups",
|
||||
"manage_oidc",
|
||||
"delete_account",
|
||||
"impersonate_users",
|
||||
]"""
|
||||
|
||||
READ: List[str] = [
|
||||
"user_name",
|
||||
"groups",
|
||||
"lock_date",
|
||||
]
|
||||
"""A list of :class:`~canaille.core.models.User` attributes that users in
|
||||
the ACL will be able to read."""
|
||||
|
||||
WRITE: List[str] = [
|
||||
"photo",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"display_name",
|
||||
"password",
|
||||
"phone_numbers",
|
||||
"emails",
|
||||
"profile_url",
|
||||
"formatted_address",
|
||||
"street",
|
||||
"postal_code",
|
||||
"locality",
|
||||
"region",
|
||||
"preferred_language",
|
||||
"employee_number",
|
||||
"department",
|
||||
"title",
|
||||
"organization",
|
||||
]
|
||||
"""A list of :class:`~canaille.core.models.User` attributes that users in
|
||||
the ACL will be able to edit."""
|
||||
|
||||
@field_validator("READ")
|
||||
def validate_read_values(
|
||||
cls,
|
||||
read: List[str],
|
||||
info: ValidationInfo,
|
||||
) -> List[str]:
|
||||
from canaille.core.models import User
|
||||
|
||||
assert all(field in User.__annotations__ for field in read)
|
||||
return read
|
||||
|
||||
@field_validator("WRITE")
|
||||
def validate_write_values(
|
||||
cls,
|
||||
write: List[str],
|
||||
info: ValidationInfo,
|
||||
) -> List[str]:
|
||||
from canaille.core.models import User
|
||||
|
||||
assert all(field in User.__annotations__ for field in write)
|
||||
return write
|
||||
|
||||
FILTER: Optional[Dict[str, str] | List[Dict[str, str]]] = None
|
||||
""":attr:`FILTER` can be:
|
||||
|
||||
- :py:data:`None`, in which case all the users will match this access control
|
||||
- a mapping where keys are user attributes name and the values those user
|
||||
attribute values. All the values must be matched for the user to be part
|
||||
of the access control.
|
||||
- a list of those mappings. If a user values match at least one mapping,
|
||||
then the user will be part of the access control
|
||||
|
||||
Here are some examples::
|
||||
|
||||
FILTER = {user_name = 'admin'}
|
||||
FILTER = [
|
||||
{groups = 'admins},
|
||||
{groups = 'moderators'},
|
||||
]
|
||||
"""
|
||||
|
||||
|
||||
class CoreSettings(BaseModel):
|
||||
"""The settings from the ``CANAILLE`` namespace.
|
||||
|
||||
Those are all the configuration parameters that controls the
|
||||
behavior of Canaille.
|
||||
"""
|
||||
|
||||
NAME: str = "Canaille"
|
||||
"""Your organization name.
|
||||
|
||||
Used for display purpose.
|
||||
"""
|
||||
|
||||
LOGO: Optional[str] = None
|
||||
"""The logo of your organization, this is useful to make your organization
|
||||
recognizable on login screens."""
|
||||
|
||||
FAVICON: Optional[str] = None
|
||||
"""You favicon.
|
||||
|
||||
If unset and :attr:`LOGO` is set, then the logo will be used.
|
||||
"""
|
||||
|
||||
THEME: str = "default"
|
||||
"""The name of a theme in the 'theme' directory, or a path to a theme.
|
||||
|
||||
Defaults to ``default``. Theming is done with `flask-themer <https://github.com/tktech/flask-themer>`_.
|
||||
"""
|
||||
|
||||
LANGUAGE: Optional[str] = None
|
||||
"""If a language code is set, it will be used for every user.
|
||||
|
||||
If unset, the language is guessed according to the users browser.
|
||||
"""
|
||||
|
||||
TIMEZONE: Optional[str] = None
|
||||
"""The timezone in which datetimes will be displayed to the users (e.g.
|
||||
``CEST``).
|
||||
|
||||
If unset, the server timezone will be used.
|
||||
"""
|
||||
|
||||
SENTRY_DSN: Optional[str] = None
|
||||
"""A `Sentry <https://sentry.io>`_ DSN to collect the exceptions.
|
||||
|
||||
This is useful for tracking errors in test and production environments.
|
||||
"""
|
||||
|
||||
JAVASCRIPT: bool = True
|
||||
"""Enables Javascript to smooth the user experience."""
|
||||
|
||||
HTMX: bool = True
|
||||
"""Accelerates webpages loading with asynchroneous requests."""
|
||||
|
||||
EMAIL_CONFIRMATION: bool = True
|
||||
"""If :py:data:`True`, users will need to click on a confirmation link sent
|
||||
by email when they want to add a new email.
|
||||
|
||||
By default, this is true
|
||||
if ``SMTP`` is configured, else this is false. If explicitely set to true
|
||||
and ``SMTP`` is disabled, the email field will be read-only.
|
||||
"""
|
||||
|
||||
ENABLE_REGISTRATION: bool = False
|
||||
"""If :py:data:`True`, then users can freely create an account at this
|
||||
instance.
|
||||
|
||||
If email verification is available, users must confirm their email
|
||||
before the account is created.
|
||||
"""
|
||||
|
||||
HIDE_INVALID_LOGINS: bool = True
|
||||
"""If :py:data:`True`, when users try to sign in with an invalid login, a
|
||||
message is shown indicating that the password is wrong, but does not give a
|
||||
clue wether the login exists or not.
|
||||
|
||||
If :py:data:`False`,
|
||||
when a user tries to sign in with an invalid login, a message is shown
|
||||
indicating that the login does not exist.
|
||||
"""
|
||||
|
||||
ENABLE_PASSWORD_RECOVERY: bool = True
|
||||
"""If :py:data:`False`, then users cannot ask for a password recovery link
|
||||
by email."""
|
||||
|
||||
INVITATION_EXPIRATION: int = 172800
|
||||
"""The validity duration of registration invitations, in seconds.
|
||||
|
||||
Defaults to 2 days.
|
||||
"""
|
||||
|
||||
LOGGING: Optional[str | Dict] = None
|
||||
"""Configures the logging output using the python logging configuration format:
|
||||
|
||||
- if :py:data:`None`, everything is logged in the standard output
|
||||
the log level is :py:data:`~logging.DEBUG` if :attr:`RootSettings.DEBUG` is :py:data:`True`, else this is :py:data:`~logging.INFO`
|
||||
- if this is a dictionnary, it is passed to :func:`logging.config.dictConfig`:
|
||||
- if this is a string, it is expected to be a file path that will be passed
|
||||
to :func:`logging.config.fileConfig`
|
||||
|
||||
For example::
|
||||
|
||||
[CANAILLE]
|
||||
LOGGING = {
|
||||
version = 1,
|
||||
formatters = {
|
||||
default = {
|
||||
format = "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
|
||||
},
|
||||
},
|
||||
handlers = {
|
||||
canaille = {
|
||||
class = "logging.handlers.WatchedFileHandler",
|
||||
filename = /foo/bar/canaille.log,
|
||||
formatter = "default",
|
||||
}
|
||||
},
|
||||
root = {
|
||||
level = "INFO",
|
||||
handlers = ["canaille"],
|
||||
},
|
||||
}
|
||||
"""
|
||||
|
||||
SMTP: Optional[SMTPSettings] = None
|
||||
"""The settings related to SMTP and mail configuration.
|
||||
|
||||
If unset, mail-related features like password recovery won't be
|
||||
enabled.
|
||||
"""
|
||||
|
||||
ACL: Optional[Dict[str, ACLSettings]] = {"DEFAULT": ACLSettings()}
|
||||
"""Mapping of permission groups. See :class:`ACLSettings` for more details.
|
||||
|
||||
The ACL name can be freely choosed. For example::
|
||||
|
||||
[CANAILLE.ACL.DEFAULT]
|
||||
PERMISSIONS = ["edit_self", "use_oidc"]
|
||||
READ = ["user_name", "groups"]
|
||||
WRITE = ["given_name", "family_name"]
|
||||
|
||||
[CANAILLE.ACL.ADMIN]
|
||||
WRITE = ["user_name", "groups"]
|
||||
"""
|
|
@ -67,7 +67,7 @@ def index():
|
|||
if user.can_edit_self or user.can_manage_users:
|
||||
return redirect(url_for("core.account.profile_edition", edited_user=user))
|
||||
|
||||
if "OIDC" in current_app.config and user.can_use_oidc:
|
||||
if "CANAILLE_OIDC" in current_app.config and user.can_use_oidc:
|
||||
return redirect(url_for("oidc.consents.consents"))
|
||||
|
||||
return redirect(url_for("core.account.about"))
|
||||
|
@ -75,10 +75,10 @@ def index():
|
|||
|
||||
@bp.route("/join", methods=("GET", "POST"))
|
||||
def join():
|
||||
if not current_app.config.get("ENABLE_REGISTRATION", False):
|
||||
if not current_app.config["CANAILLE"]["ENABLE_REGISTRATION"]:
|
||||
abort(404)
|
||||
|
||||
if not current_app.config.get("EMAIL_CONFIRMATION", True):
|
||||
if not current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"]:
|
||||
return redirect(url_for(".registration"))
|
||||
|
||||
if current_user():
|
||||
|
@ -163,13 +163,10 @@ class VerificationPayload:
|
|||
return datetime.datetime.fromisoformat(self.creation_date_isoformat)
|
||||
|
||||
def has_expired(self):
|
||||
DEFAULT_INVITATION_DURATION = 2 * 24 * 60 * 60
|
||||
return datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
) - self.creation_date > datetime.timedelta(
|
||||
seconds=current_app.config.get(
|
||||
"INVITATION_EXPIRATION", DEFAULT_INVITATION_DURATION
|
||||
)
|
||||
seconds=current_app.config["CANAILLE"]["INVITATION_EXPIRATION"]
|
||||
)
|
||||
|
||||
def b64(self):
|
||||
|
@ -235,9 +232,10 @@ def user_invitation(user):
|
|||
def registration(data=None, hash=None):
|
||||
if not data:
|
||||
payload = None
|
||||
if not current_app.config.get(
|
||||
"ENABLE_REGISTRATION", False
|
||||
) or current_app.config.get("EMAIL_CONFIRMATION", True):
|
||||
if (
|
||||
not current_app.config["CANAILLE"]["ENABLE_REGISTRATION"]
|
||||
or current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"]
|
||||
):
|
||||
abort(403)
|
||||
|
||||
else:
|
||||
|
@ -285,9 +283,9 @@ def registration(data=None, hash=None):
|
|||
"groups": [models.Group.get(id=group_id) for group_id in payload.groups],
|
||||
}
|
||||
|
||||
has_smtp = "SMTP" in current_app.config
|
||||
emails_readonly = current_app.config.get("EMAIL_CONFIRMATION") is True or (
|
||||
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
|
||||
has_smtp = "SMTP" in current_app.config["CANAILLE"]
|
||||
emails_readonly = current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is True or (
|
||||
current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None and has_smtp
|
||||
)
|
||||
readable_fields, writable_fields = default_fields()
|
||||
|
||||
|
@ -581,9 +579,11 @@ def profile_edition(user, edited_user):
|
|||
abort(404)
|
||||
|
||||
menuitem = "profile" if edited_user.id == user.id else "users"
|
||||
has_smtp = "SMTP" in current_app.config
|
||||
has_email_confirmation = current_app.config.get("EMAIL_CONFIRMATION") is True or (
|
||||
current_app.config.get("EMAIL_CONFIRMATION") is None and has_smtp
|
||||
has_smtp = "SMTP" in current_app.config["CANAILLE"]
|
||||
has_email_confirmation = current_app.config["CANAILLE"][
|
||||
"EMAIL_CONFIRMATION"
|
||||
] is True or (
|
||||
current_app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None and has_smtp
|
||||
)
|
||||
emails_readonly = has_email_confirmation and not user.can_manage_users
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -142,7 +142,7 @@ def firstlogin(user):
|
|||
@bp.route("/reset", methods=["GET", "POST"])
|
||||
@smtp_needed()
|
||||
def forgotten():
|
||||
if not current_app.config.get("ENABLE_PASSWORD_RECOVERY", True):
|
||||
if not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]:
|
||||
abort(404)
|
||||
|
||||
form = ForgottenPasswordForm(request.form)
|
||||
|
@ -158,7 +158,7 @@ def forgotten():
|
|||
"A password reset link has been sent at your email address. "
|
||||
"You should receive it within a few minutes."
|
||||
)
|
||||
if current_app.config.get("HIDE_INVALID_LOGINS", True) and (
|
||||
if current_app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] and (
|
||||
not user or not user.can_edit_self
|
||||
):
|
||||
flash(success_message, "success")
|
||||
|
@ -191,7 +191,7 @@ def forgotten():
|
|||
|
||||
@bp.route("/reset/<user:user>/<hash>", methods=["GET", "POST"])
|
||||
def reset(user, hash):
|
||||
if not current_app.config.get("ENABLE_PASSWORD_RECOVERY", True):
|
||||
if not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]:
|
||||
abort(404)
|
||||
|
||||
form = PasswordResetForm(request.form)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
106
canaille/oidc/configuration.py
Normal file
106
canaille/oidc/configuration.py
Normal file
|
@ -0,0 +1,106 @@
|
|||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class JWTMappingSettings(BaseModel):
|
||||
"""Mapping between the user model and the JWT fields.
|
||||
|
||||
Fiels are evaluated with jinja.
|
||||
A ``user`` var is available.
|
||||
"""
|
||||
|
||||
SUB: Optional[str] = "{{ user.user_name }}"
|
||||
NAME: Optional[str] = (
|
||||
"{% if user.formatted_name %}{{ user.formatted_name }}{% endif %}"
|
||||
)
|
||||
PHONE_NUMBER: Optional[str] = (
|
||||
"{% if user.phone_numbers %}{{ user.phone_numbers[0] }}{% endif %}"
|
||||
)
|
||||
EMAIL: Optional[str] = (
|
||||
"{% if user.preferred_email %}{{ user.preferred_email }}{% endif %}"
|
||||
)
|
||||
GIVEN_NAME: Optional[str] = (
|
||||
"{% if user.given_name %}{{ user.given_name }}{% endif %}"
|
||||
)
|
||||
FAMILY_NAME: Optional[str] = (
|
||||
"{% if user.family_name %}{{ user.family_name }}{% endif %}"
|
||||
)
|
||||
PREFERRED_USERNAME: Optional[str] = (
|
||||
"{% if user.display_name %}{{ user.display_name }}{% endif %}"
|
||||
)
|
||||
LOCALE: Optional[str] = (
|
||||
"{% if user.preferred_language %}{{ user.preferred_language }}{% endif %}"
|
||||
)
|
||||
ADDRESS: Optional[str] = (
|
||||
"{% if user.formatted_address %}{{ user.formatted_address }}{% endif %}"
|
||||
)
|
||||
PICTURE: Optional[str] = (
|
||||
"{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
|
||||
)
|
||||
WEBSITE: Optional[str] = (
|
||||
"{% if user.profile_url %}{{ user.profile_url }}{% endif %}"
|
||||
)
|
||||
|
||||
|
||||
class JWTSettings(BaseModel):
|
||||
"""JSON Web Token settings. Belong in the ``CANAILLE_OIDC.JWT`` namespace.
|
||||
|
||||
You can generate a RSA keypair with::
|
||||
|
||||
openssl genrsa -out private.pem 4096
|
||||
openssl rsa -in private.pem -pubout -outform PEM -out public.pem
|
||||
"""
|
||||
|
||||
PRIVATE_KEY: Optional[str] = None
|
||||
"""The private key.
|
||||
|
||||
If :py:data:`None` and debug mode is enabled, then an in-memory key will be used.
|
||||
"""
|
||||
|
||||
PUBLIC_KEY: Optional[str] = None
|
||||
"""The public key.
|
||||
|
||||
If :py:data:`None` and debug mode is enabled, then an in-memory key will be used.
|
||||
"""
|
||||
|
||||
ISS: Optional[str] = None
|
||||
"""The URI of the identity provider."""
|
||||
|
||||
KTY: str = "RSA"
|
||||
"""The key type."""
|
||||
|
||||
ALG: str = "RS256"
|
||||
"""The key algorithm."""
|
||||
|
||||
EXP: int = 3600
|
||||
"""The time the JWT will be valid, in seconds."""
|
||||
|
||||
MAPPING: Optional[JWTMappingSettings] = JWTMappingSettings()
|
||||
|
||||
|
||||
class OIDCSettings(BaseModel):
|
||||
"""OpenID Connect settings.
|
||||
|
||||
Belong in the ``CANAILLE_OIDC`` namespace.
|
||||
"""
|
||||
|
||||
DYNAMIC_CLIENT_REGISTRATION_OPEN: bool = False
|
||||
"""Wether a token is needed for the RFC7591 dynamical client registration.
|
||||
|
||||
If :py:data:`True`, no token is needed to register a client.
|
||||
If :py:data:`False`, dynamical client registration needs a token defined in :attr:`DYNAMIC_CLIENT_REGISTRATION_TOKENS`.
|
||||
"""
|
||||
|
||||
DYNAMIC_CLIENT_REGISTRATION_TOKENS: Optional[List[str]] = None
|
||||
"""A list of tokens that can be used for dynamic client registration."""
|
||||
|
||||
REQUIRE_NONCE: bool = True
|
||||
"""Force the nonce exchange during the authentication flows.
|
||||
|
||||
This adds security but may not be supported by all clients.
|
||||
"""
|
||||
|
||||
JWT: Optional[JWTSettings] = None
|
||||
"""JSON Web Token settings."""
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -35,23 +35,7 @@ from werkzeug.security import gen_salt
|
|||
|
||||
from canaille.app import models
|
||||
|
||||
DEFAULT_JWT_KTY = "RSA"
|
||||
DEFAULT_JWT_ALG = "RS256"
|
||||
DEFAULT_JWT_EXP = 3600
|
||||
AUTHORIZATION_CODE_LIFETIME = 84400
|
||||
DEFAULT_JWT_MAPPING = {
|
||||
"SUB": "{{ user.user_name }}",
|
||||
"NAME": "{% if user.formatted_name %}{{ user.formatted_name }}{% endif %}",
|
||||
"PHONE_NUMBER": "{% if user.phone_numbers %}{{ user.phone_numbers[0] }}{% endif %}",
|
||||
"EMAIL": "{% if user.preferred_email %}{{ user.preferred_email }}{% endif %}",
|
||||
"GIVEN_NAME": "{% if user.given_name %}{{ user.given_name }}{% endif %}",
|
||||
"FAMILY_NAME": "{% if user.family_name %}{{ user.family_name }}{% endif %}",
|
||||
"PREFERRED_USERNAME": "{% if user.display_name %}{{ user.display_name }}{% endif %}",
|
||||
"LOCALE": "{% if user.preferred_language %}{{ user.preferred_language }}{% endif %}",
|
||||
"ADDRESS": "{% if user.formatted_address %}{{ user.formatted_address }}{% endif %}",
|
||||
"PICTURE": "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}",
|
||||
"WEBSITE": "{% if user.profile_url %}{{ user.profile_url }}{% endif %}",
|
||||
}
|
||||
|
||||
|
||||
def oauth_authorization_server():
|
||||
|
@ -121,7 +105,7 @@ def openid_configuration():
|
|||
"subject_types_supported": ["pairwise", "public"],
|
||||
"id_token_signing_alg_values_supported": ["RS256", "ES256", "HS256"],
|
||||
"prompt_values_supported": ["none"]
|
||||
+ (["create"] if current_app.config.get("ENABLE_REGISTRATION") else []),
|
||||
+ (["create"] if current_app.config["CANAILLE"]["ENABLE_REGISTRATION"] else []),
|
||||
}
|
||||
|
||||
|
||||
|
@ -132,8 +116,8 @@ def exists_nonce(nonce, req):
|
|||
|
||||
|
||||
def get_issuer():
|
||||
if current_app.config["OIDC"]["JWT"].get("ISS"):
|
||||
return current_app.config["OIDC"]["JWT"].get("ISS")
|
||||
if current_app.config["CANAILLE_OIDC"]["JWT"]["ISS"]:
|
||||
return current_app.config["CANAILLE_OIDC"]["JWT"]["ISS"]
|
||||
|
||||
if current_app.config.get("SERVER_NAME"):
|
||||
return current_app.config.get("SERVER_NAME")
|
||||
|
@ -143,18 +127,18 @@ def get_issuer():
|
|||
|
||||
def get_jwt_config(grant=None):
|
||||
return {
|
||||
"key": current_app.config["OIDC"]["JWT"]["PRIVATE_KEY"],
|
||||
"alg": current_app.config["OIDC"]["JWT"].get("ALG", DEFAULT_JWT_ALG),
|
||||
"key": current_app.config["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"],
|
||||
"alg": current_app.config["CANAILLE_OIDC"]["JWT"]["ALG"],
|
||||
"iss": get_issuer(),
|
||||
"exp": current_app.config["OIDC"]["JWT"].get("EXP", DEFAULT_JWT_EXP),
|
||||
"exp": current_app.config["CANAILLE_OIDC"]["JWT"]["EXP"],
|
||||
}
|
||||
|
||||
|
||||
def get_jwks():
|
||||
kty = current_app.config["OIDC"]["JWT"].get("KTY", DEFAULT_JWT_KTY)
|
||||
alg = current_app.config["OIDC"]["JWT"].get("ALG", DEFAULT_JWT_ALG)
|
||||
kty = current_app.config["CANAILLE_OIDC"]["JWT"]["KTY"]
|
||||
alg = current_app.config["CANAILLE_OIDC"]["JWT"]["ALG"]
|
||||
jwk = JsonWebKey.import_key(
|
||||
current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"], {"kty": kty}
|
||||
current_app.config["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"], {"kty": kty}
|
||||
)
|
||||
return {
|
||||
"keys": [
|
||||
|
@ -204,8 +188,7 @@ def generate_user_info(user, scope):
|
|||
|
||||
def generate_user_claims(user, claims, jwt_mapping_config=None):
|
||||
jwt_mapping_config = {
|
||||
**DEFAULT_JWT_MAPPING,
|
||||
**(current_app.config["OIDC"]["JWT"].get("MAPPING") or {}),
|
||||
**(current_app.config["CANAILLE_OIDC"]["JWT"]["MAPPING"]),
|
||||
**(jwt_mapping_config or {}),
|
||||
}
|
||||
|
||||
|
@ -420,9 +403,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
|
|||
|
||||
class ClientManagementMixin:
|
||||
def authenticate_token(self, request):
|
||||
if current_app.config.get("OIDC", {}).get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN", False
|
||||
):
|
||||
if current_app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"]:
|
||||
return True
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
|
@ -431,7 +412,7 @@ class ClientManagementMixin:
|
|||
|
||||
bearer_token = auth_header.split()[1]
|
||||
if bearer_token not in (
|
||||
current_app.config.get("OIDC", {}).get("DYNAMIC_CLIENT_REGISTRATION_TOKENS")
|
||||
current_app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"]
|
||||
or []
|
||||
):
|
||||
return None
|
||||
|
@ -445,7 +426,7 @@ class ClientManagementMixin:
|
|||
def resolve_public_key(self, request):
|
||||
# At the moment the only keypair accepted in software statement
|
||||
# is the one used to isues JWTs. This might change somedays.
|
||||
return current_app.config["OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
return current_app.config["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
|
||||
def client_convert_data(self, **kwargs):
|
||||
if "client_id_issued_at" in kwargs:
|
||||
|
@ -533,6 +514,9 @@ def generate_access_token(client, grant_type, user, scope):
|
|||
|
||||
|
||||
def setup_oauth(app):
|
||||
# hacky, but needed for tests as somehow the same 'authorization' object is used
|
||||
# between tests
|
||||
authorization.__init__()
|
||||
authorization.init_app(app, query_client=query_client, save_token=save_token)
|
||||
|
||||
authorization.register_grant(PasswordGrant)
|
||||
|
@ -543,9 +527,7 @@ def setup_oauth(app):
|
|||
authorization.register_grant(
|
||||
AuthorizationCodeGrant,
|
||||
[
|
||||
OpenIDCode(
|
||||
require_nonce=app.config.get("OIDC", {}).get("REQUIRE_NONCE", True)
|
||||
),
|
||||
OpenIDCode(require_nonce=app.config["CANAILLE_OIDC"]["REQUIRE_NONCE"]),
|
||||
CodeChallenge(required=True),
|
||||
],
|
||||
)
|
||||
|
|
|
@ -1,152 +1,23 @@
|
|||
# All the Flask configuration values can be used:
|
||||
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
|
||||
|
||||
# The flask secret key for cookies. You MUST change this.
|
||||
SECRET_KEY = "change me before you go in production"
|
||||
|
||||
# Your organization name.
|
||||
[CANAILLE]
|
||||
NAME = "Canaille"
|
||||
|
||||
# The interface on which canaille will be served
|
||||
# SERVER_NAME = "auth.mydomain.tld"
|
||||
# PREFERRED_URL_SCHEME = "https"
|
||||
|
||||
# You can display a logo to be recognized on login screens
|
||||
LOGO = "/static/img/canaille-head.webp"
|
||||
|
||||
# Your favicon. If unset the LOGO will be used.
|
||||
FAVICON = "/static/img/canaille-c.webp"
|
||||
|
||||
# The name of a theme in the 'theme' directory, or a path path
|
||||
# to a theme. Defaults to 'default'. Theming is done with
|
||||
# https://github.com/tktech/flask-themer
|
||||
# THEME = "default"
|
||||
|
||||
# If unset, language is detected
|
||||
# LANGUAGE = "en"
|
||||
|
||||
# The timezone in which datetimes will be displayed to the users.
|
||||
# If unset, the server timezone will be used.
|
||||
# TIMEZONE = UTC
|
||||
|
||||
# If you have a sentry instance, you can set its dsn here:
|
||||
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
|
||||
# Enables javascript to smooth the user experience
|
||||
# JAVASCRIPT = true
|
||||
|
||||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
EMAIL_CONFIRMATION = false
|
||||
|
||||
# If ENABLE_REGISTRATION is true, then users can freely create an account
|
||||
# at this instance. If email verification is available, users must confirm
|
||||
# their email before the account is created.
|
||||
ENABLE_REGISTRATION = true
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
# exists or not.
|
||||
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
|
||||
# an invalid login, a message is shown indicating that the login does not
|
||||
# exist.
|
||||
# HIDE_INVALID_LOGINS = true
|
||||
|
||||
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
|
||||
# recovery link by email. This option is true by default.
|
||||
# ENABLE_PASSWORD_RECOVERY = true
|
||||
|
||||
# The validity duration of registration invitations, in seconds.
|
||||
# Defaults to 2 days
|
||||
# INVITATION_EXPIRATION = 172800
|
||||
|
||||
# LOGGING configures the logging output:
|
||||
# - if unset, everything is logged in the standard output
|
||||
# the log level is debug if DEBUG is True, else this is INFO
|
||||
# - if this is a dictionnary, it is passed to the python dictConfig method:
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
|
||||
# - if this is a string, it is passed to the python fileConfig method
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
|
||||
|
||||
# [BACKENDS.SQL]
|
||||
# The SQL database connection string
|
||||
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
|
||||
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
|
||||
|
||||
[BACKENDS.LDAP]
|
||||
[CANAILLE_LDAP]
|
||||
URI = "ldap://ldap:389"
|
||||
ROOT_DN = "dc=mydomain,dc=tld"
|
||||
BIND_DN = "cn=admin,dc=mydomain,dc=tld"
|
||||
BIND_PW = "admin"
|
||||
TIMEOUT = 10
|
||||
|
||||
# Where to search for users?
|
||||
USER_BASE = "ou=users,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new users
|
||||
# USER_CLASS = "inetOrgPerson"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
USER_RDN = "uid"
|
||||
|
||||
# Filter to match users on sign in. Jinja syntax is supported
|
||||
# and a `login` variable is available containing the value
|
||||
# passed in the login field.
|
||||
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
|
||||
|
||||
# Where to search for groups?
|
||||
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new groups
|
||||
# GROUP_CLASS = "groupOfNames"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# GROUP_RDN = "cn"
|
||||
|
||||
# The attribute to use to identify a group
|
||||
# GROUP_NAME_ATTRIBUTE = "cn"
|
||||
|
||||
[ACL]
|
||||
# You can define access controls that define what users can do on canaille
|
||||
# An access control consists in a FILTER to match users, a list of PERMISSIONS
|
||||
# matched users will be able to perform, and fields users will be able
|
||||
# to READ and WRITE. Users matching several filters will cumulate permissions.
|
||||
#
|
||||
# 'FILTER' parameter can be:
|
||||
# - absent, in which case all the users will match this access control
|
||||
# - a mapping where keys are user attributes name and the values those user
|
||||
# attribute values. All the values must be matched for the user to be part
|
||||
# of the access control.
|
||||
# - a list of those mappings. If a user values match at least one mapping,
|
||||
# then the user will be part of the access control
|
||||
#
|
||||
# Here are some examples
|
||||
# FILTER = {user_name = 'admin'}
|
||||
# FILTER =
|
||||
# - {groups = 'admins'}
|
||||
# - {groups = 'moderators'}
|
||||
#
|
||||
# The 'PERMISSIONS' parameter that is an list of items the users in the access
|
||||
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
|
||||
# - "edit_self" to allow users to edit their own profile
|
||||
# - "use_oidc" to allow OpenID Connect authentication
|
||||
# - "manage_oidc" to allow OpenID Connect client managements
|
||||
# - "manage_users" to allow other users management
|
||||
# - "manage_groups" to allow group edition and creation
|
||||
# - "delete_account" allows a user to delete his own account. If used with
|
||||
# manage_users, the user can delete any account
|
||||
# - "impersonate_users" to allow a user to take the identity of another user
|
||||
#
|
||||
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
|
||||
# object that users will be able to read and/or write.
|
||||
[ACL.DEFAULT]
|
||||
[CANAILLE.ACL.DEFAULT]
|
||||
PERMISSIONS = ["edit_self", "use_oidc"]
|
||||
READ = [
|
||||
"user_name",
|
||||
|
@ -174,7 +45,7 @@ WRITE = [
|
|||
"organization",
|
||||
]
|
||||
|
||||
[ACL.ADMIN]
|
||||
[CANAILLE.ACL.ADMIN]
|
||||
FILTER = {groups = "admins"}
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
|
@ -188,75 +59,13 @@ WRITE = [
|
|||
"lock_date",
|
||||
]
|
||||
|
||||
[ACL.HALF_ADMIN]
|
||||
[CANAILLE.ACL.HALF_ADMIN]
|
||||
FILTER = {groups = "moderators"}
|
||||
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
|
||||
WRITE = ["groups"]
|
||||
|
||||
[OIDC]
|
||||
# Wether a token is needed for the RFC7591 dynamical client registration.
|
||||
# If true, no token is needed to register a client.
|
||||
# If false, dynamical client registration needs a token defined
|
||||
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
|
||||
[CANAILLE_OIDC]
|
||||
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
||||
|
||||
# A list of tokens that can be used for dynamic client registration
|
||||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
|
||||
# This adds security but may not be supported by all clients.
|
||||
# REQUIRE_NONCE = true
|
||||
|
||||
[OIDC.JWT]
|
||||
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
|
||||
# the public key. You can generate a RSA keypair with:
|
||||
# openssl genrsa -out private.pem 4096
|
||||
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
|
||||
# If the variables are unset, and debug mode is enabled,
|
||||
# a in-memory keypair will be used.
|
||||
# PRIVATE_KEY_FILE = "/path/to/private.pem"
|
||||
# PUBLIC_KEY_FILE = "/path/to/public.pem"
|
||||
# The URI of the identity provider
|
||||
# ISS = "https://auth.mydomain.tld"
|
||||
# The key type parameter
|
||||
# KTY = "RSA"
|
||||
# The key algorithm
|
||||
# ALG = "RS256"
|
||||
# The time the JWT will be valid, in seconds
|
||||
# EXP = 3600
|
||||
|
||||
[OIDC.JWT.MAPPING]
|
||||
# Mapping between JWT fields and LDAP attributes from your
|
||||
# User objectClass.
|
||||
# {attribute} will be replaced by the user ldap attribute value.
|
||||
# Default values fits inetOrgPerson.
|
||||
# SUB = "{{ user.user_name }}"
|
||||
# NAME = "{{ user.formatted_name }}"
|
||||
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
|
||||
# EMAIL = "{{ user.preferred_email }}"
|
||||
# GIVEN_NAME = "{{ user.given_name }}"
|
||||
# FAMILY_NAME = "{{ user.family_name }}"
|
||||
# PREFERRED_USERNAME = "{{ user.display_name }}"
|
||||
# LOCALE = "{{ user.preferred_language }}"
|
||||
# ADDRESS = "{{ user.formatted_address }}"
|
||||
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
|
||||
# WEBSITE = "{{ user.profile_url }}"
|
||||
|
||||
# The SMTP server options. If not set, mail related features such as
|
||||
# user invitations, and password reset emails, will be disabled.
|
||||
[SMTP]
|
||||
# HOST = "localhost"
|
||||
# PORT = 25
|
||||
# TLS = false
|
||||
# SSL = false
|
||||
# LOGIN = ""
|
||||
# PASSWORD = ""
|
||||
# FROM_ADDR = "admin@mydomain.tld"
|
||||
|
||||
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
|
||||
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
|
||||
# [REGISTRATION]
|
||||
# GROUPS=[]
|
||||
# CAN_EDIT_USERNAME = false
|
||||
|
|
|
@ -1,152 +1,13 @@
|
|||
# All the Flask configuration values can be used:
|
||||
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
|
||||
|
||||
# The flask secret key for cookies. You MUST change this.
|
||||
SECRET_KEY = "change me before you go in production"
|
||||
|
||||
# Your organization name.
|
||||
[CANAILLE]
|
||||
NAME = "Canaille"
|
||||
|
||||
# The interface on which canaille will be served
|
||||
# SERVER_NAME = "auth.mydomain.tld"
|
||||
# PREFERRED_URL_SCHEME = "https"
|
||||
|
||||
# You can display a logo to be recognized on login screens
|
||||
LOGO = "/static/img/canaille-head.webp"
|
||||
|
||||
# Your favicon. If unset the LOGO will be used.
|
||||
FAVICON = "/static/img/canaille-c.webp"
|
||||
|
||||
# The name of a theme in the 'theme' directory, or a path path
|
||||
# to a theme. Defaults to 'default'. Theming is done with
|
||||
# https://github.com/tktech/flask-themer
|
||||
# THEME = "default"
|
||||
|
||||
# If unset, language is detected
|
||||
# LANGUAGE = "en"
|
||||
|
||||
# The timezone in which datetimes will be displayed to the users.
|
||||
# If unset, the server timezone will be used.
|
||||
# TIMEZONE = UTC
|
||||
|
||||
# If you have a sentry instance, you can set its dsn here:
|
||||
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
|
||||
# Enables javascript to smooth the user experience
|
||||
# JAVASCRIPT = true
|
||||
|
||||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
EMAIL_CONFIRMATION = false
|
||||
|
||||
# If ENABLE_REGISTRATION is true, then users can freely create an account
|
||||
# at this instance. If email verification is available, users must confirm
|
||||
# their email before the account is created.
|
||||
ENABLE_REGISTRATION = true
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
# exists or not.
|
||||
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
|
||||
# an invalid login, a message is shown indicating that the login does not
|
||||
# exist.
|
||||
# HIDE_INVALID_LOGINS = true
|
||||
|
||||
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
|
||||
# recovery link by email. This option is true by default.
|
||||
# ENABLE_PASSWORD_RECOVERY = true
|
||||
|
||||
# The validity duration of registration invitations, in seconds.
|
||||
# Defaults to 2 days
|
||||
# INVITATION_EXPIRATION = 172800
|
||||
|
||||
# LOGGING configures the logging output:
|
||||
# - if unset, everything is logged in the standard output
|
||||
# the log level is debug if DEBUG is True, else this is INFO
|
||||
# - if this is a dictionnary, it is passed to the python dictConfig method:
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
|
||||
# - if this is a string, it is passed to the python fileConfig method
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
|
||||
|
||||
# [BACKENDS.SQL]
|
||||
# The SQL database connection string
|
||||
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
|
||||
# SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
|
||||
|
||||
# [BACKENDS.LDAP]
|
||||
# URI = "ldap://ldap:389"
|
||||
# ROOT_DN = "dc=mydomain,dc=tld"
|
||||
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
|
||||
# BIND_PW = "admin"
|
||||
# TIMEOUT = 10
|
||||
|
||||
# Where to search for users?
|
||||
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new users
|
||||
# USER_CLASS = "inetOrgPerson"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# USER_RDN = "uid"
|
||||
|
||||
# Filter to match users on sign in. Jinja syntax is supported
|
||||
# and a `login` variable is available containing the value
|
||||
# passed in the login field.
|
||||
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
|
||||
|
||||
# Where to search for groups?
|
||||
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new groups
|
||||
# GROUP_CLASS = "groupOfNames"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# GROUP_RDN = "cn"
|
||||
|
||||
# The attribute to use to identify a group
|
||||
# GROUP_NAME_ATTRIBUTE = "cn"
|
||||
|
||||
[ACL]
|
||||
# You can define access controls that define what users can do on canaille
|
||||
# An access control consists in a FILTER to match users, a list of PERMISSIONS
|
||||
# matched users will be able to perform, and fields users will be able
|
||||
# to READ and WRITE. Users matching several filters will cumulate permissions.
|
||||
#
|
||||
# 'FILTER' parameter can be:
|
||||
# - absent, in which case all the users will match this access control
|
||||
# - a mapping where keys are user attributes name and the values those user
|
||||
# attribute values. All the values must be matched for the user to be part
|
||||
# of the access control.
|
||||
# - a list of those mappings. If a user values match at least one mapping,
|
||||
# then the user will be part of the access control
|
||||
#
|
||||
# Here are some examples
|
||||
# FILTER = {user_name = 'admin'}
|
||||
# FILTER =
|
||||
# - {groups = 'admins'}
|
||||
# - {groups = 'moderators'}
|
||||
#
|
||||
# The 'PERMISSIONS' parameter that is an list of items the users in the access
|
||||
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
|
||||
# - "edit_self" to allow users to edit their own profile
|
||||
# - "use_oidc" to allow OpenID Connect authentication
|
||||
# - "manage_oidc" to allow OpenID Connect client managements
|
||||
# - "manage_users" to allow other users management
|
||||
# - "manage_groups" to allow group edition and creation
|
||||
# - "delete_account" allows a user to delete his own account. If used with
|
||||
# manage_users, the user can delete any account
|
||||
# - "impersonate_users" to allow a user to take the identity of another user
|
||||
#
|
||||
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
|
||||
# object that users will be able to read and/or write.
|
||||
[ACL.DEFAULT]
|
||||
[CANAILLE.ACL.DEFAULT]
|
||||
PERMISSIONS = ["edit_self", "use_oidc"]
|
||||
READ = [
|
||||
"user_name",
|
||||
|
@ -174,7 +35,7 @@ WRITE = [
|
|||
"organization",
|
||||
]
|
||||
|
||||
[ACL.ADMIN]
|
||||
[CANAILLE.ACL.ADMIN]
|
||||
FILTER = {groups = "admins"}
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
|
@ -188,75 +49,13 @@ WRITE = [
|
|||
"lock_date",
|
||||
]
|
||||
|
||||
[ACL.HALF_ADMIN]
|
||||
[CANAILLE.ACL.HALF_ADMIN]
|
||||
FILTER = {groups = "moderators"}
|
||||
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
|
||||
WRITE = ["groups"]
|
||||
|
||||
[OIDC]
|
||||
# Wether a token is needed for the RFC7591 dynamical client registration.
|
||||
# If true, no token is needed to register a client.
|
||||
# If false, dynamical client registration needs a token defined
|
||||
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
|
||||
[CANAILLE_OIDC]
|
||||
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
||||
|
||||
# A list of tokens that can be used for dynamic client registration
|
||||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
|
||||
# This adds security but may not be supported by all clients.
|
||||
# REQUIRE_NONCE = true
|
||||
|
||||
[OIDC.JWT]
|
||||
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
|
||||
# the public key. You can generate a RSA keypair with:
|
||||
# openssl genrsa -out private.pem 4096
|
||||
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
|
||||
# If the variables are unset, and debug mode is enabled,
|
||||
# a in-memory keypair will be used.
|
||||
# PRIVATE_KEY_FILE = "/path/to/private.pem"
|
||||
# PUBLIC_KEY_FILE = "/path/to/public.pem"
|
||||
# The URI of the identity provider
|
||||
# ISS = "https://auth.mydomain.tld"
|
||||
# The key type parameter
|
||||
# KTY = "RSA"
|
||||
# The key algorithm
|
||||
# ALG = "RS256"
|
||||
# The time the JWT will be valid, in seconds
|
||||
# EXP = 3600
|
||||
|
||||
[OIDC.JWT.MAPPING]
|
||||
# Mapping between JWT fields and LDAP attributes from your
|
||||
# User objectClass.
|
||||
# {attribute} will be replaced by the user ldap attribute value.
|
||||
# Default values fits inetOrgPerson.
|
||||
# SUB = "{{ user.user_name }}"
|
||||
# NAME = "{{ user.formatted_name }}"
|
||||
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
|
||||
# EMAIL = "{{ user.preferred_email }}"
|
||||
# GIVEN_NAME = "{{ user.given_name }}"
|
||||
# FAMILY_NAME = "{{ user.family_name }}"
|
||||
# PREFERRED_USERNAME = "{{ user.display_name }}"
|
||||
# LOCALE = "{{ user.preferred_language }}"
|
||||
# ADDRESS = "{{ user.formatted_address }}"
|
||||
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
|
||||
# WEBSITE = "{{ user.profile_url }}"
|
||||
|
||||
# The SMTP server options. If not set, mail related features such as
|
||||
# user invitations, and password reset emails, will be disabled.
|
||||
[SMTP]
|
||||
# HOST = "localhost"
|
||||
# PORT = 25
|
||||
# TLS = false
|
||||
# SSL = false
|
||||
# LOGIN = ""
|
||||
# PASSWORD = ""
|
||||
# FROM_ADDR = "admin@mydomain.tld"
|
||||
|
||||
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
|
||||
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
|
||||
# [REGISTRATION]
|
||||
# GROUPS=[]
|
||||
# CAN_EDIT_USERNAME = false
|
||||
|
|
|
@ -1,152 +1,16 @@
|
|||
# All the Flask configuration values can be used:
|
||||
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
|
||||
|
||||
# The flask secret key for cookies. You MUST change this.
|
||||
SECRET_KEY = "change me before you go in production"
|
||||
|
||||
# Your organization name.
|
||||
[CANAILLE]
|
||||
NAME = "Canaille"
|
||||
|
||||
# The interface on which canaille will be served
|
||||
# SERVER_NAME = "auth.mydomain.tld"
|
||||
# PREFERRED_URL_SCHEME = "https"
|
||||
|
||||
# You can display a logo to be recognized on login screens
|
||||
LOGO = "/static/img/canaille-head.webp"
|
||||
|
||||
# Your favicon. If unset the LOGO will be used.
|
||||
FAVICON = "/static/img/canaille-c.webp"
|
||||
|
||||
# The name of a theme in the 'theme' directory, or a path path
|
||||
# to a theme. Defaults to 'default'. Theming is done with
|
||||
# https://github.com/tktech/flask-themer
|
||||
# THEME = "default"
|
||||
|
||||
# If unset, language is detected
|
||||
# LANGUAGE = "en"
|
||||
|
||||
# The timezone in which datetimes will be displayed to the users.
|
||||
# If unset, the server timezone will be used.
|
||||
# TIMEZONE = UTC
|
||||
|
||||
# If you have a sentry instance, you can set its dsn here:
|
||||
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
|
||||
# Enables javascript to smooth the user experience
|
||||
# JAVASCRIPT = true
|
||||
|
||||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
EMAIL_CONFIRMATION = false
|
||||
|
||||
# If ENABLE_REGISTRATION is true, then users can freely create an account
|
||||
# at this instance. If email verification is available, users must confirm
|
||||
# their email before the account is created.
|
||||
ENABLE_REGISTRATION = true
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
# exists or not.
|
||||
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
|
||||
# an invalid login, a message is shown indicating that the login does not
|
||||
# exist.
|
||||
# HIDE_INVALID_LOGINS = true
|
||||
[CANAILLE_SQL]
|
||||
DATABASE_URI = "sqlite:///demo.sqlite"
|
||||
|
||||
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
|
||||
# recovery link by email. This option is true by default.
|
||||
# ENABLE_PASSWORD_RECOVERY = true
|
||||
|
||||
# The validity duration of registration invitations, in seconds.
|
||||
# Defaults to 2 days
|
||||
# INVITATION_EXPIRATION = 172800
|
||||
|
||||
# LOGGING configures the logging output:
|
||||
# - if unset, everything is logged in the standard output
|
||||
# the log level is debug if DEBUG is True, else this is INFO
|
||||
# - if this is a dictionnary, it is passed to the python dictConfig method:
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
|
||||
# - if this is a string, it is passed to the python fileConfig method
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
|
||||
|
||||
[BACKENDS.SQL]
|
||||
# The SQL database connection string
|
||||
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
|
||||
SQL_DATABASE_URI = "sqlite:///demo.sqlite"
|
||||
|
||||
# [BACKENDS.LDAP]
|
||||
# URI = "ldap://ldap:389"
|
||||
# ROOT_DN = "dc=mydomain,dc=tld"
|
||||
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
|
||||
# BIND_PW = "admin"
|
||||
# TIMEOUT = 10
|
||||
|
||||
# Where to search for users?
|
||||
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new users
|
||||
# USER_CLASS = "inetOrgPerson"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# USER_RDN = "uid"
|
||||
|
||||
# Filter to match users on sign in. Jinja syntax is supported
|
||||
# and a `login` variable is available containing the value
|
||||
# passed in the login field.
|
||||
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
|
||||
|
||||
# Where to search for groups?
|
||||
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new groups
|
||||
# GROUP_CLASS = "groupOfNames"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# GROUP_RDN = "cn"
|
||||
|
||||
# The attribute to use to identify a group
|
||||
# GROUP_NAME_ATTRIBUTE = "cn"
|
||||
|
||||
[ACL]
|
||||
# You can define access controls that define what users can do on canaille
|
||||
# An access control consists in a FILTER to match users, a list of PERMISSIONS
|
||||
# matched users will be able to perform, and fields users will be able
|
||||
# to READ and WRITE. Users matching several filters will cumulate permissions.
|
||||
#
|
||||
# 'FILTER' parameter can be:
|
||||
# - absent, in which case all the users will match this access control
|
||||
# - a mapping where keys are user attributes name and the values those user
|
||||
# attribute values. All the values must be matched for the user to be part
|
||||
# of the access control.
|
||||
# - a list of those mappings. If a user values match at least one mapping,
|
||||
# then the user will be part of the access control
|
||||
#
|
||||
# Here are some examples
|
||||
# FILTER = {user_name = 'admin'}
|
||||
# FILTER =
|
||||
# - {groups = 'admins'}
|
||||
# - {groups = 'moderators'}
|
||||
#
|
||||
# The 'PERMISSIONS' parameter that is an list of items the users in the access
|
||||
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
|
||||
# - "edit_self" to allow users to edit their own profile
|
||||
# - "use_oidc" to allow OpenID Connect authentication
|
||||
# - "manage_oidc" to allow OpenID Connect client managements
|
||||
# - "manage_users" to allow other users management
|
||||
# - "manage_groups" to allow group edition and creation
|
||||
# - "delete_account" allows a user to delete his own account. If used with
|
||||
# manage_users, the user can delete any account
|
||||
# - "impersonate_users" to allow a user to take the identity of another user
|
||||
#
|
||||
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
|
||||
# object that users will be able to read and/or write.
|
||||
[ACL.DEFAULT]
|
||||
[CANAILLE.ACL.DEFAULT]
|
||||
PERMISSIONS = ["edit_self", "use_oidc"]
|
||||
READ = [
|
||||
"user_name",
|
||||
|
@ -174,7 +38,7 @@ WRITE = [
|
|||
"organization",
|
||||
]
|
||||
|
||||
[ACL.ADMIN]
|
||||
[CANAILLE.ACL.ADMIN]
|
||||
FILTER = {groups = "admins"}
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
|
@ -188,75 +52,13 @@ WRITE = [
|
|||
"lock_date",
|
||||
]
|
||||
|
||||
[ACL.HALF_ADMIN]
|
||||
[CANAILLE.ACL.HALF_ADMIN]
|
||||
FILTER = {groups = "moderators"}
|
||||
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
|
||||
WRITE = ["groups"]
|
||||
|
||||
[OIDC]
|
||||
# Wether a token is needed for the RFC7591 dynamical client registration.
|
||||
# If true, no token is needed to register a client.
|
||||
# If false, dynamical client registration needs a token defined
|
||||
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
|
||||
[CANAILLE_OIDC]
|
||||
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
||||
|
||||
# A list of tokens that can be used for dynamic client registration
|
||||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
|
||||
# This adds security but may not be supported by all clients.
|
||||
# REQUIRE_NONCE = true
|
||||
|
||||
[OIDC.JWT]
|
||||
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
|
||||
# the public key. You can generate a RSA keypair with:
|
||||
# openssl genrsa -out private.pem 4096
|
||||
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
|
||||
# If the variables are unset, and debug mode is enabled,
|
||||
# a in-memory keypair will be used.
|
||||
# PRIVATE_KEY_FILE = "/path/to/private.pem"
|
||||
# PUBLIC_KEY_FILE = "/path/to/public.pem"
|
||||
# The URI of the identity provider
|
||||
# ISS = "https://auth.mydomain.tld"
|
||||
# The key type parameter
|
||||
# KTY = "RSA"
|
||||
# The key algorithm
|
||||
# ALG = "RS256"
|
||||
# The time the JWT will be valid, in seconds
|
||||
# EXP = 3600
|
||||
|
||||
[OIDC.JWT.MAPPING]
|
||||
# Mapping between JWT fields and LDAP attributes from your
|
||||
# User objectClass.
|
||||
# {attribute} will be replaced by the user ldap attribute value.
|
||||
# Default values fits inetOrgPerson.
|
||||
# SUB = "{{ user.user_name }}"
|
||||
# NAME = "{{ user.formatted_name }}"
|
||||
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
|
||||
# EMAIL = "{{ user.preferred_email }}"
|
||||
# GIVEN_NAME = "{{ user.given_name }}"
|
||||
# FAMILY_NAME = "{{ user.family_name }}"
|
||||
# PREFERRED_USERNAME = "{{ user.display_name }}"
|
||||
# LOCALE = "{{ user.preferred_language }}"
|
||||
# ADDRESS = "{{ user.formatted_address }}"
|
||||
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
|
||||
# WEBSITE = "{{ user.profile_url }}"
|
||||
|
||||
# The SMTP server options. If not set, mail related features such as
|
||||
# user invitations, and password reset emails, will be disabled.
|
||||
[SMTP]
|
||||
# HOST = "localhost"
|
||||
# PORT = 25
|
||||
# TLS = false
|
||||
# SSL = false
|
||||
# LOGIN = ""
|
||||
# PASSWORD = ""
|
||||
# FROM_ADDR = "admin@mydomain.tld"
|
||||
|
||||
# The registration options. If not set, registration will be disabled. Requires SMTP to work.
|
||||
# Groups should be formatted like this: ["<GROUP_NAME_ATTRIBUTE>=group_name,<GROUP_BASE>", ...]
|
||||
# [REGISTRATION]
|
||||
# GROUPS=[]
|
||||
# CAN_EDIT_USERNAME = false
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,152 +1,15 @@
|
|||
# All the Flask configuration values can be used:
|
||||
# https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values
|
||||
|
||||
# The flask secret key for cookies. You MUST change this.
|
||||
SECRET_KEY = "change me before you go in production"
|
||||
|
||||
# Your organization name.
|
||||
# NAME = "Canaille"
|
||||
|
||||
# The interface on which canaille will be served
|
||||
# SERVER_NAME = "auth.mydomain.tld"
|
||||
# PREFERRED_URL_SCHEME = "https"
|
||||
|
||||
# You can display a logo to be recognized on login screens
|
||||
[CANAILLE]
|
||||
LOGO = "/static/img/canaille-head.webp"
|
||||
|
||||
# Your favicon. If unset the LOGO will be used.
|
||||
FAVICON = "/static/img/canaille-c.webp"
|
||||
|
||||
# The name of a theme in the 'theme' directory, or a path path
|
||||
# to a theme. Defaults to 'default'. Theming is done with
|
||||
# https://github.com/tktech/flask-themer
|
||||
# THEME = "default"
|
||||
|
||||
# If unset, language is detected
|
||||
# LANGUAGE = "en"
|
||||
|
||||
# The timezone in which datetimes will be displayed to the users.
|
||||
# If unset, the server timezone will be used.
|
||||
# TIMEZONE = UTC
|
||||
|
||||
# If you have a sentry instance, you can set its dsn here:
|
||||
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
|
||||
# Enables javascript to smooth the user experience
|
||||
# JAVASCRIPT = true
|
||||
|
||||
# Accelerates webpages with async requests
|
||||
# HTMX = true
|
||||
|
||||
# If EMAIL_CONFIRMATION is set to true, users will need to click on a
|
||||
# confirmation link sent by email when they want to add a new email.
|
||||
# By default, this is true if SMTP is configured, else this is false.
|
||||
# If explicitely set to true and SMTP is disabled, the email field
|
||||
# will be read-only.
|
||||
EMAIL_CONFIRMATION = false
|
||||
|
||||
# If ENABLE_REGISTRATION is true, then users can freely create an account
|
||||
# at this instance. If email verification is available, users must confirm
|
||||
# their email before the account is created.
|
||||
ENABLE_REGISTRATION = true
|
||||
|
||||
# If HIDE_INVALID_LOGINS is set to true (the default), when a user
|
||||
# tries to sign in with an invalid login, a message is shown indicating
|
||||
# that the password is wrong, but does not give a clue wether the login
|
||||
# exists or not.
|
||||
# If HIDE_INVALID_LOGINS is set to false, when a user tries to sign in with
|
||||
# an invalid login, a message is shown indicating that the login does not
|
||||
# exist.
|
||||
# HIDE_INVALID_LOGINS = true
|
||||
[CANAILLE_SQL]
|
||||
DATABASE_URI = "sqlite:///demo.sqlite"
|
||||
|
||||
# If ENABLE_PASSWORD_RECOVERY is false, then users cannot ask for a password
|
||||
# recovery link by email. This option is true by default.
|
||||
# ENABLE_PASSWORD_RECOVERY = true
|
||||
|
||||
# The validity duration of registration invitations, in seconds.
|
||||
# Defaults to 2 days
|
||||
# INVITATION_EXPIRATION = 172800
|
||||
|
||||
# LOGGING configures the logging output:
|
||||
# - if unset, everything is logged in the standard output
|
||||
# the log level is debug if DEBUG is True, else this is INFO
|
||||
# - if this is a dictionnary, it is passed to the python dictConfig method:
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig
|
||||
# - if this is a string, it is passed to the python fileConfig method
|
||||
# https://docs.python.org/3/library/logging.config.html#logging.config.fileConfig
|
||||
|
||||
[BACKENDS.SQL]
|
||||
# The SQL database connection string
|
||||
# Details on https://docs.sqlalchemy.org/en/20/core/engines.html
|
||||
SQL_DATABASE_URI = "sqlite:///demo.sqlite"
|
||||
|
||||
# [BACKENDS.LDAP]
|
||||
# URI = "ldap://localhost"
|
||||
# ROOT_DN = "dc=mydomain,dc=tld"
|
||||
# BIND_DN = "cn=admin,dc=mydomain,dc=tld"
|
||||
# BIND_PW = "admin"
|
||||
# TIMEOUT = 10
|
||||
|
||||
# Where to search for users?
|
||||
# USER_BASE = "ou=users,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new users
|
||||
# USER_CLASS = "inetOrgPerson"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# USER_RDN = "uid"
|
||||
|
||||
# Filter to match users on sign in. Jinja syntax is supported
|
||||
# and a `login` variable is available containing the value
|
||||
# passed in the login field.
|
||||
# USER_FILTER = "(|(uid={{ login }})(mail={{ login }}))"
|
||||
|
||||
# Where to search for groups?
|
||||
# GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# The object class to use for creating new groups
|
||||
# GROUP_CLASS = "groupOfNames"
|
||||
|
||||
# The attribute to identify an object in the User dn.
|
||||
# GROUP_RDN = "cn"
|
||||
|
||||
# The attribute to use to identify a group
|
||||
# GROUP_NAME_ATTRIBUTE = "cn"
|
||||
|
||||
[ACL]
|
||||
# You can define access controls that define what users can do on canaille
|
||||
# An access control consists in a FILTER to match users, a list of PERMISSIONS
|
||||
# matched users will be able to perform, and fields users will be able
|
||||
# to READ and WRITE. Users matching several filters will cumulate permissions.
|
||||
#
|
||||
# 'FILTER' parameter can be:
|
||||
# - absent, in which case all the users will match this access control
|
||||
# - a mapping where keys are user attributes name and the values those user
|
||||
# attribute values. All the values must be matched for the user to be part
|
||||
# of the access control.
|
||||
# - a list of those mappings. If a user values match at least one mapping,
|
||||
# then the user will be part of the access control
|
||||
#
|
||||
# Here are some examples
|
||||
# FILTER = {user_name = 'admin'}
|
||||
# FILTER =
|
||||
# - {groups = 'admins'}
|
||||
# - {groups = 'moderators'}
|
||||
#
|
||||
# The 'PERMISSIONS' parameter that is an list of items the users in the access
|
||||
# control will be able to manage. 'PERMISSIONS' is optionnal. Values can be:
|
||||
# - "edit_self" to allow users to edit their own profile
|
||||
# - "use_oidc" to allow OpenID Connect authentication
|
||||
# - "manage_oidc" to allow OpenID Connect client managements
|
||||
# - "manage_users" to allow other users management
|
||||
# - "manage_groups" to allow group edition and creation
|
||||
# - "delete_account" allows a user to delete his own account. If used with
|
||||
# manage_users, the user can delete any account
|
||||
# - "impersonate_users" to allow a user to take the identity of another user
|
||||
#
|
||||
# The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
|
||||
# object that users will be able to read and/or write.
|
||||
[ACL.DEFAULT]
|
||||
[CANAILLE.ACL.DEFAULT]
|
||||
PERMISSIONS = ["edit_self", "use_oidc"]
|
||||
READ = [
|
||||
"user_name",
|
||||
|
@ -174,7 +37,7 @@ WRITE = [
|
|||
"organization",
|
||||
]
|
||||
|
||||
[ACL.ADMIN]
|
||||
[CANAILLE.ACL.ADMIN]
|
||||
FILTER = {groups = "admins"}
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
|
@ -188,73 +51,13 @@ WRITE = [
|
|||
"lock_date",
|
||||
]
|
||||
|
||||
[ACL.HALF_ADMIN]
|
||||
[CANAILLE.ACL.HALF_ADMIN]
|
||||
FILTER = {groups = "moderators"}
|
||||
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
|
||||
WRITE = ["groups"]
|
||||
|
||||
# The jwt configuration. You can generate a RSA keypair with:
|
||||
# openssl genrsa -out private.pem 4096
|
||||
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
|
||||
|
||||
[OIDC]
|
||||
# Wether a token is needed for the RFC7591 dynamical client registration.
|
||||
# If true, no token is needed to register a client.
|
||||
# If false, dynamical client registration needs a token defined
|
||||
# in DYNAMIC_CLIENT_REGISTRATION_TOKENS
|
||||
[CANAILLE_OIDC]
|
||||
DYNAMIC_CLIENT_REGISTRATION_OPEN = true
|
||||
|
||||
# A list of tokens that can be used for dynamic client registration
|
||||
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
|
||||
"xxxxxxx-yyyyyyy-zzzzzz",
|
||||
]
|
||||
|
||||
# REQUIRE_NONCE force the nonce exchange during the authentication flows.
|
||||
# This adds security but may not be supported by all clients.
|
||||
# REQUIRE_NONCE = true
|
||||
|
||||
[OIDC.JWT]
|
||||
# PRIVATE_KEY_FILE and PUBLIC_KEY_FILE are the paths to the private and
|
||||
# the public key. You can generate a RSA keypair with:
|
||||
# openssl genrsa -out private.pem 4096
|
||||
# openssl rsa -in private.pem -pubout -outform PEM -out public.pem
|
||||
# If the variables are unset, and debug mode is enabled,
|
||||
# a in-memory keypair will be used.
|
||||
# PRIVATE_KEY_FILE = "/path/to/private.pem"
|
||||
# PUBLIC_KEY_FILE = "/path/to/public.pem"
|
||||
# The URI of the identity provider
|
||||
# ISS = "https://auth.mydomain.tld"
|
||||
# The key type parameter
|
||||
# KTY = "RSA"
|
||||
# The key algorithm
|
||||
# ALG = "RS256"
|
||||
# The time the JWT will be valid, in seconds
|
||||
# EXP = 3600
|
||||
|
||||
[OIDC.JWT.MAPPING]
|
||||
# Mapping between JWT fields and LDAP attributes from your
|
||||
# User objectClass.
|
||||
# {attribute} will be replaced by the user ldap attribute value.
|
||||
# Default values fits inetOrgPerson.
|
||||
# SUB = "{{ user.user_name }}"
|
||||
# NAME = "{{ user.formatted_name }}"
|
||||
# PHONE_NUMBER = "{{ user.phone_numbers[0] }}"
|
||||
# EMAIL = "{{ user.preferred_email }}"
|
||||
# GIVEN_NAME = "{{ user.given_name }}"
|
||||
# FAMILY_NAME = "{{ user.family_name }}"
|
||||
# PREFERRED_USERNAME = "{{ user.display_name }}"
|
||||
# LOCALE = "{{ user.preferred_language }}"
|
||||
# ADDRESS = "{{ user.formatted_address }}"
|
||||
# PICTURE = "{% if user.photo %}{{ url_for('core.account.photo', user=user, field='photo', _external=True) }}{% endif %}"
|
||||
# WEBSITE = "{{ user.profile_url }}"
|
||||
|
||||
# The SMTP server options. If not set, mail related features such as
|
||||
# user invitations, and password reset emails, will be disabled.
|
||||
[SMTP]
|
||||
# HOST = "localhost"
|
||||
# PORT = 25
|
||||
# TLS = false
|
||||
# SSL = false
|
||||
# LOGIN = ""
|
||||
# PASSWORD = ""
|
||||
# FROM_ADDR = "admin@mydomain.tld"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
13
doc/conf.py
13
doc/conf.py
|
@ -34,8 +34,10 @@ extensions = [
|
|||
"sphinx.ext.intersphinx",
|
||||
"sphinx.ext.todo",
|
||||
"sphinx.ext.viewcode",
|
||||
"sphinx_enum_extend",
|
||||
"sphinx_issues",
|
||||
"sphinx_sitemap",
|
||||
"sphinxcontrib.autodoc_pydantic",
|
||||
]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
|
@ -61,6 +63,7 @@ intersphinx_mapping = {
|
|||
"flask": ("https://flask.palletsprojects.com", None),
|
||||
"flask-babel": ("https://python-babel.github.io/flask-babel", None),
|
||||
"flask-wtf": ("https://flask-wtf.readthedocs.io", None),
|
||||
"pydantic": ("https://docs.pydantic.dev/latest", None),
|
||||
}
|
||||
|
||||
issues_uri = "https://gitlab.com/yaal/canaille/-/issues/{issue}"
|
||||
|
@ -122,3 +125,13 @@ texinfo_documents = [
|
|||
|
||||
autosectionlabel_prefix_document = True
|
||||
autosectionlabel_maxdepth = 2
|
||||
|
||||
# -- Options for autodo_pydantic_settings -------------------------------------------
|
||||
|
||||
autodoc_pydantic_settings_show_json = False
|
||||
autodoc_pydantic_settings_show_config_summary = False
|
||||
autodoc_pydantic_settings_show_config_summary = False
|
||||
autodoc_pydantic_settings_show_validator_summary = False
|
||||
autodoc_pydantic_settings_show_validator_members = False
|
||||
autodoc_pydantic_settings_show_field_summary = False
|
||||
autodoc_pydantic_field_list_validators = False
|
||||
|
|
|
@ -1,327 +1,61 @@
|
|||
Configuration
|
||||
#############
|
||||
|
||||
Here are the different options you can have in your configuration file.
|
||||
Canaille can be configured either by a environment variables, or by a `toml` configuration file which path is passed in the ``CONFIG`` environment variable.
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
Toml file
|
||||
=========
|
||||
|
||||
.. note ::
|
||||
::
|
||||
|
||||
Any configuration entry can be suffixed by *_FILE* and point to the path of
|
||||
a file that contains the actual value. For instance you could have
|
||||
``SECRET_KEY_FILE = "/path/to/secret.txt"`` instead of ``SECRET_KEY = "very-secret"``
|
||||
SECRET_KEY = "very-secret"
|
||||
|
||||
Sections
|
||||
========
|
||||
[CANAILLE]
|
||||
NAME = "My organization"
|
||||
|
||||
Miscellaneous
|
||||
-------------
|
||||
Canaille is based on Flask, so any `flask configuration <https://flask.palletsprojects.com/en/3.0.x/config/#builtin-configuration-values>`_ option will be usable with canaille:
|
||||
[CANAILLE_SQL]
|
||||
DATABASE_URI = "postgresql://user:password@localhost/database"
|
||||
...
|
||||
|
||||
:SECRET_KEY:
|
||||
**Required.** The Flask secret key. You should set a random string here.
|
||||
You can have a look at the :ref:`configuration:Example file` for inspiration.
|
||||
|
||||
.. note ::
|
||||
Environment variables
|
||||
=====================
|
||||
|
||||
Remember that you can also use SECRET_KEY_FILE to store the secret key
|
||||
outside the configuration file.
|
||||
In addition, parameters that have not been set in the configuration file can be read from environment variables.
|
||||
The way environment variables are parsed can be read from the `pydantic-settings documentation <https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values>`_.
|
||||
|
||||
:NAME:
|
||||
*Optional.* The name of your organization. If not set `Canaille` will be used.
|
||||
Settings will also be read from a local ``.env`` file if present.
|
||||
|
||||
:LOGO:
|
||||
*Optional.* The URL ot the logo of your organization. The default is the canaille logo.
|
||||
Secret parameters
|
||||
=================
|
||||
|
||||
:FAVICON:
|
||||
*Optional.* An URL to a favicon. The default is the value of ``LOGO``.
|
||||
A ``SECRETS_DIR`` environment variable can be passed as an environment variable, being a path to a directory in which are stored files named after the configuration settings.
|
||||
|
||||
:THEME:
|
||||
*Optional.* The name or the path to a canaille theme.
|
||||
If the value is just a name, the theme should be in a directory with that name in the *themes* directory.
|
||||
For instance, you can set ``SECRETS_DIR=/run/secrets`` and put your secret key in the file ``/run/secrets/SECRET_KEY``.
|
||||
|
||||
:LANGUAGE:
|
||||
*Optional.* The locale code of the language to use. If not set, the language of the browser will be used.
|
||||
Parameters
|
||||
==========
|
||||
|
||||
:TIMEZONE:
|
||||
*Optional.* The timezone in which datetimes will be displayed to the users. If unset, the server timezone will be used.
|
||||
.. autopydantic_settings:: canaille.app.configuration.RootSettings
|
||||
|
||||
:JAVASCRIPT:
|
||||
*Optional.* Wether javascript is used to smooth the user experience.
|
||||
.. autopydantic_settings:: canaille.core.configuration.CoreSettings
|
||||
.. autopydantic_settings:: canaille.core.configuration.SMTPSettings
|
||||
.. autopydantic_settings:: canaille.core.configuration.ACLSettings
|
||||
.. auto_autoenum:: canaille.core.configuration.Permission
|
||||
|
||||
:HTMX:
|
||||
*Optional.* Wether `HTMX <https://htmx.org>`_ will be used to accelerate webpages. Defaults to true.
|
||||
.. autopydantic_settings:: canaille.oidc.configuration.OIDCSettings
|
||||
|
||||
:SENTRY_DSN:
|
||||
*Optional.* A DSN to a sentry instance.
|
||||
This needs the ``sentry_sdk`` python package to be installed.
|
||||
This is useful if you want to collect the canaille exceptions in a production environment.
|
||||
.. autopydantic_settings:: canaille.oidc.configuration.JWTSettings
|
||||
.. autopydantic_settings:: canaille.oidc.configuration.JWTMappingSettings
|
||||
|
||||
:ENABLE_REGISTRATION:
|
||||
*Optional.* If true, then users can freely create an account
|
||||
at this instance. If ``EMAIL_CONFIRMATION`` is true, users must confirm
|
||||
their email before the account is created.
|
||||
Defaults to false.
|
||||
.. autopydantic_settings:: canaille.backends.sql.configuration.SQLSettings
|
||||
.. autopydantic_settings:: canaille.backends.ldap.configuration.LDAPSettings
|
||||
|
||||
:EMAIL_CONFIRMATION:
|
||||
*Optional.* If set to true, users will need to click on
|
||||
a confirmation link sent by email when they want to add a new email. By default,
|
||||
this is true if SMTP is configured, else this is false. If explicitely set to
|
||||
true and SMTP is disabled, the email field will be read-only.
|
||||
Example file
|
||||
============
|
||||
|
||||
:HIDE_INVALID_LOGINS:
|
||||
*Optional.* Wether to tell the users if a username exists during failing login attempts.
|
||||
Defaults to ``True``. This may be a security issue to disable this, as this give a way to malicious people to if an account exists on this canaille instance.
|
||||
Here is a configuration file example:
|
||||
|
||||
:ENABLE_PASSWORD_RECOVERY:
|
||||
*Optional* Wether the password recovery feature is enabled or not.
|
||||
Defaults to ``True``.
|
||||
|
||||
:INVITATION_EXPIRATION:
|
||||
*Optional* The validity duration of registration invitations, in seconds.
|
||||
Defaults to 2 days.
|
||||
|
||||
LOGGING
|
||||
-------
|
||||
|
||||
:LEVEL:
|
||||
*Optional.* The logging level. Must be an either *DEBUG*, *INFO*, *WARNING*, *ERROR* or *CRITICAL*. Defults to *WARNING*.
|
||||
|
||||
:PATH:
|
||||
*Optional.* The log file path. If not set, logs are written in the standard error output.
|
||||
|
||||
BACKENDS.SQL
|
||||
------------
|
||||
|
||||
:SQL_DATABASE_URI:
|
||||
**Required.** The SQL database connection string, as defined in
|
||||
`SQLAlchemy documentation <https://docs.sqlalchemy.org/en/20/core/engines.html>`_.
|
||||
|
||||
BACKENDS.LDAP
|
||||
-------------
|
||||
|
||||
:URI:
|
||||
**Required.** The URI to the LDAP server.
|
||||
e.g. ``ldaps://ldad.mydomain.tld``
|
||||
|
||||
:ROOT_DN:
|
||||
**Required.** The root DN of your LDAP server.
|
||||
e.g. ``dc=mydomain,dc=tld``
|
||||
|
||||
:BIND_DN:
|
||||
**Required.** The LDAP DN to bind with.
|
||||
e.g. ``cn=admin,dc=mydomain,dc=tld``
|
||||
|
||||
:BIND_PW:
|
||||
**Required.** The LDAP user associated with ``BIND_DN``.
|
||||
|
||||
:TIMEOUT:
|
||||
*Optional.* The time to wait for the LDAP server to respond before considering it is not functional.
|
||||
|
||||
:USER_BASE:
|
||||
**Required.** The DN of the node in which users will be searched for, and created.
|
||||
e.g. ``ou=users,dc=mydomain,dc=tld``
|
||||
|
||||
:USER_CLASS:
|
||||
*Optional.* The LDAP object class to filter existing users, and create new users.
|
||||
Can be a list of classes.
|
||||
Defaults to ``inetOrgPerson``.
|
||||
|
||||
:USER_RDN:
|
||||
*Optional.* The attribute to identify an object in the User DN.
|
||||
For example, if it has the value ``uid``, users DN will be in the form ``uid=foobar,ou=users,dc=mydomain,dc=tld``.
|
||||
Defaults to ``cn``.
|
||||
|
||||
:USER_FILTER:
|
||||
*Optional.* The filter to match users on sign in.
|
||||
Jinja syntax is supported and a `login` variable is available containing
|
||||
the value passed in the login field.
|
||||
Defaults to ``(|(uid={{ login }})(mail={{ login }}))``
|
||||
|
||||
:GROUP_BASE:
|
||||
**Required.** The DN where of the node in which LDAP groups will be created and searched for.
|
||||
e.g. ``ou=groups,dc=mydomain,dc=tld``
|
||||
|
||||
:GROUP_CLASS:
|
||||
*Optional.* The LDAP object class to filter existing groups, and create new groups.
|
||||
Can be a list of classes.
|
||||
Defaults to ``groupOfNames``
|
||||
|
||||
:GROUP_RDN:
|
||||
*Optional.* The attribute to identify an object in a group DN.
|
||||
For example, if it has the value ``cn``, groups DN will be in the form ``cn=foobar,ou=users,dc=mydomain,dc=tld``.
|
||||
Defaults to ``cn``
|
||||
|
||||
:GROUP_NAME_ATTRIBUTE:
|
||||
*Optional.* The attribute to identify a group in the web interface.
|
||||
Defaults to ``cn``
|
||||
|
||||
ACL
|
||||
---
|
||||
You can define access controls that define what users can do on canaille
|
||||
An access control consists in a ``FILTER`` to match users, a list of ``PERMISSIONS`` that users will be able to perform, and fields users will be able
|
||||
to ``READ`` and ``WRITE``. Users matching several filters will cumulate permissions.
|
||||
|
||||
The 'READ' and 'WRITE' attributes are the LDAP attributes of the user
|
||||
object that users will be able to read and/or write.
|
||||
|
||||
:FILTER:
|
||||
*Optional.* It can be:
|
||||
|
||||
- absent, in which case all the users will have the permissions in this ACL.
|
||||
- a mapping where keys are user attributes name and the values those user
|
||||
attribute values. All the values must be matched for the user to be part
|
||||
of the access control.
|
||||
- a list of those mappings. If a user values match at least one mapping,
|
||||
then the user will be part of the access control
|
||||
|
||||
Here are some examples:
|
||||
|
||||
- ``FILTER = {'user_name': 'admin'}``
|
||||
- ``FILTER = [{'groups': 'admin'}, {'groups': 'moderators'}]``
|
||||
|
||||
:PERMISSIONS:
|
||||
*Optional.* A list of items the users in the access control will be able to manage. Values can be:
|
||||
|
||||
- **edit_self** to allow users to edit their own profile
|
||||
- **use_oidc** to allow OpenID Connect authentication
|
||||
- **manage_oidc** to allow OpenID Connect client managements
|
||||
- **manage_users** to allow other users management
|
||||
- **manage_groups** to allow group edition and creation
|
||||
- **delete_account** allows a user to delete his own account. If used with *manage_users*, the user can delete any account
|
||||
- **impersonate_users** to allow a user to take the identity of another user
|
||||
|
||||
:READ:
|
||||
*Optional.* A list of attributes of ``USER_CLASS`` the user will be able to see, but not edit.
|
||||
If the users has the ``edit_self`` permission, they will be able to see those fields on their own account.
|
||||
If the users has the ``manage_users`` permission, the user will be able to see this fields on other users profile.
|
||||
If the list containts the special ``groups`` field, the user will be able to see the groups he belongs to.
|
||||
|
||||
:WRITE:
|
||||
*Optional.* A list of attributes of ``USER_CLASS`` the user will be able to edit.
|
||||
If the users has the ``edit_self`` permission, they will be able to edit those fields on their own account.
|
||||
If the users has the ``manage_users`` permission, they will be able to edit those fields on other users profile.
|
||||
If the list containts the special ``groups`` field, the user will be able to edit the groups he belongs to.
|
||||
|
||||
OIDC
|
||||
----
|
||||
|
||||
:DYNAMIC_CLIENT_REGISTRATION_OPEN:
|
||||
*Optional.* Wether a token is needed for the RFC7591 dynamical client registration.
|
||||
If true, no token is needed to register a client.
|
||||
If false, dynamical client registration needs a token defined
|
||||
in `DYNAMIC_CLIENT_REGISTRATION_TOKENS``
|
||||
Defaults to ``False``
|
||||
|
||||
:DYNAMIC_CLIENT_REGISTRATION_TOKENS:
|
||||
*Optional.* A list of tokens that can be used for dynamic client registration
|
||||
|
||||
:REQUIRE_NONE:
|
||||
*Optional.* Forces the nonce exchange during the authentication flows.
|
||||
This adds security but may not be supported by all clients.
|
||||
Defaults to ``True``
|
||||
|
||||
OIDC.JWT
|
||||
--------
|
||||
Canaille needs a key pair to sign the JWT. The installation command will generate a key pair for you, but you can also do it manually. In debug mode, a in-memory keypair will be used.
|
||||
|
||||
:PRIVATE_KEY:
|
||||
**Required.** The content of the private key..
|
||||
|
||||
:PUBLIC_KEY:
|
||||
**Required.** The content of the public key.
|
||||
|
||||
.. note ::
|
||||
|
||||
Remember that you can also use PRIVATE_KEY_FILE and PUBLIC_KEY_FILE
|
||||
to store the keys outside the configuration file.
|
||||
|
||||
:ISS:
|
||||
*Optional.* The URI of the identity provider.
|
||||
Defaults to ``SERVER_NAME`` if set, else the current domain will be used.
|
||||
e.g. ``https://auth.mydomain.tld``
|
||||
|
||||
:KTY:
|
||||
*Optional.* The key type parameter.
|
||||
Defaults to ``RSA``.
|
||||
|
||||
:ALG:
|
||||
*Optional.* The key algorithm.
|
||||
Defaults to ``RS256``.
|
||||
|
||||
:EXP:
|
||||
*Optional.* The time the JWT will be valid, in seconds.
|
||||
Defaults to ``3600``
|
||||
|
||||
OIDC.JWT.MAPPINGS
|
||||
-----------------
|
||||
|
||||
A mapping where keys are JWT claims, and values are LDAP user object attributes.
|
||||
Attributes are rendered using jinja2, and can use a ``user`` variable.
|
||||
|
||||
:SUB:
|
||||
*Optional.* Defaults to ``{{ user.user_name }}``
|
||||
|
||||
:NAME:
|
||||
*Optional.* Defaults to ``{{ user.cn[0] }}``
|
||||
|
||||
:PHONE_NUMBER:
|
||||
*Optional.* Defaults to ``{{ user.phone_number[0] }}``
|
||||
|
||||
:EMAIL:
|
||||
*Optional.* Defaults to ``{{ user.mail[0] }}``
|
||||
|
||||
:GIVEN_NAME:
|
||||
*Optional.* Defaults to ``{{ user.given_name }}``
|
||||
|
||||
:FAMILY_NAME:
|
||||
*Optional.* Defaults to ``{{ user.family_name }}``
|
||||
|
||||
:PREFERRED_USERNAME:
|
||||
*Optional.* Defaults to ``{{ user.display_name[0] }}``
|
||||
|
||||
:LOCALE:
|
||||
*Optional.* Defaults to ``{{ user.locale }}``
|
||||
|
||||
:ADDRESS:
|
||||
*Optional.* Defaults to ``{{ user.address[0] }}``
|
||||
|
||||
:PICTURE:
|
||||
*Optional.* Defaults to ``{% if user.photo %}{{ url_for('core.account.photo', user_name=user.user_name, field='photo', _external=True) }}{% endif %}``
|
||||
|
||||
:WEBSITE:
|
||||
*Optional.* Defaults to ``{{ user.profile_url }}``
|
||||
|
||||
|
||||
SMTP
|
||||
----
|
||||
Canaille needs you to configure a SMTP server to send some mails, including the *I forgot my password* and the *invitation* mails.
|
||||
Without this section Canaille will still be usable, but all the features related to mail will be disabled.
|
||||
|
||||
:HOST:
|
||||
The SMTP server to connect to.
|
||||
Defaults to ``localhost``
|
||||
|
||||
:PORT:
|
||||
The port to use with the SMTP connection.
|
||||
Defaults to ``25``
|
||||
|
||||
:TLS:
|
||||
Whether the SMTP connection use TLS.
|
||||
Default to ``False``
|
||||
|
||||
:SSL:
|
||||
Whether the SMTP connection use SSL.
|
||||
Default to ``False``
|
||||
|
||||
:LOGIN:
|
||||
The SMTP server authentication login.
|
||||
*Optional.*
|
||||
|
||||
:PASSWORD:
|
||||
The SMTP server authentication password.
|
||||
*Optional.*
|
||||
|
||||
:FROM_ADDR:
|
||||
*Optional.* The mail address to use as the sender for Canaille emails.
|
||||
Defaults to `admin@<HOSTNAME>` where `HOSTNAME` is the current hostname.
|
||||
.. literalinclude :: ../canaille/config.sample.toml
|
||||
:language: toml
|
||||
|
|
|
@ -11,7 +11,7 @@ Memory
|
|||
======
|
||||
|
||||
Canaille comes with a lightweight inmemory backend by default.
|
||||
It is used when no other backend has been configured, i.e. when the ``BACKENDS`` configuration parameter is unset or empty.
|
||||
It is used when no other backend has been configured.
|
||||
|
||||
This backend is only for test purpose and should not be used in production environments.
|
||||
|
||||
|
@ -21,18 +21,20 @@ SQL
|
|||
Canaille can use any database supported by `SQLAlchemy <https://www.sqlalchemy.org/>`_, such as
|
||||
sqlite, postgresql or mariadb.
|
||||
|
||||
It is used when the ``BACKENDS.SQL`` configuration parameter is defined. For instance::
|
||||
It is used when the ``CANAILLE_SQL`` configuration parameter is defined. For instance::
|
||||
|
||||
[BACKENDS.SQL]
|
||||
[CANAILLE_SQL]
|
||||
SQL_DATABASE_URI = "postgresql://user:password@localhost/database"
|
||||
|
||||
You can find more details on the SQL configuration in the :class:`~canaille.backends.sql.configuration.SQLSettings` section.
|
||||
|
||||
LDAP
|
||||
====
|
||||
|
||||
Canaille can use OpenLDAP as its main database.
|
||||
It is used when the ``BACKENDS.LDAP`` configuration parameter is defined. For instance::
|
||||
It is used when the ``CANAILLE_LDAP`` configuration parameter is defined. For instance::
|
||||
|
||||
[BACKENDS.LDAP]
|
||||
[CANAILLE_LDAP]
|
||||
URI = "ldap://ldap"
|
||||
ROOT_DN = "dc=mydomain,dc=tld"
|
||||
BIND_DN = "cn=admin,dc=mydomain,dc=tld"
|
||||
|
@ -44,6 +46,8 @@ It is used when the ``BACKENDS.LDAP`` configuration parameter is defined. For in
|
|||
|
||||
GROUP_BASE = "ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
You can find more details on the LDAP configuration in the :class:`~canaille.backends.ldap.configuration.LDAPSettings` section.
|
||||
|
||||
.. note ::
|
||||
Currently, only the ``inetOrgPerson`` and ``groupOfNames`` schemas have been tested.
|
||||
If you want to use different schemas or LDAP servers, adaptations may be needed.
|
||||
|
|
|
@ -153,12 +153,12 @@ Apache
|
|||
RewriteRule "^/.well-know/webfinger" "https://auth.mydomain.tld/.well-known/webfinger" [R,L]
|
||||
</VirtualHost>
|
||||
|
||||
Create your first user
|
||||
======================
|
||||
Create the first user
|
||||
=====================
|
||||
|
||||
Once canaille is installed, you have several ways to populate the database. The obvious one is by adding
|
||||
directly users and group into your LDAP directory. You might also want to temporarily enable then
|
||||
``ENABLE_REGISTRATION`` configuration parameter to allow you to create your first users. Then, if you
|
||||
directly users and group into your LDAP directory. You might also want to temporarily enable then the
|
||||
:attr:`~canaille.core.configuration.CoreSettings.ENABLE_REGISTRATION` configuration parameter to allow you to create your first users. Then, if you
|
||||
have configured your ACLs properly then you will be able to manage users and groups through the Canaille
|
||||
interface.
|
||||
|
||||
|
|
|
@ -6,4 +6,4 @@ The web interface throws unuseful error messages
|
|||
|
||||
Unless the current user has admin permissions, or the installation is in debug mode, error messages won't be too technical.
|
||||
For instance, you can see *The request you made is invalid*.
|
||||
To enable detailed error messages, you can **temporarily** set the ``DEBUG=true`` configuration parameter.
|
||||
To enable detailed error messages, you can **temporarily** enable the :attr:`~canaille.core.configuration.RootSettings.DEBUG` configuration parameter.
|
||||
|
|
207
poetry.lock
generated
207
poetry.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,68 +5,97 @@ from flask_webtest import TestApp
|
|||
|
||||
from canaille import create_app
|
||||
from canaille.app.configuration import ConfigurationException
|
||||
from canaille.app.configuration import settings_factory
|
||||
from canaille.app.configuration import validate
|
||||
|
||||
|
||||
def test_configuration_file_suffix(tmp_path, backend, configuration):
|
||||
file_path = os.path.join(tmp_path, "secret.txt")
|
||||
os.environ["SECRETS_DIR"] = str(tmp_path)
|
||||
file_path = os.path.join(tmp_path, "SECRET_KEY")
|
||||
with open(file_path, "w") as fd:
|
||||
fd.write("very-secret")
|
||||
|
||||
del configuration["SECRET_KEY"]
|
||||
configuration["SECRET_KEY_FILE"] = file_path
|
||||
|
||||
app = create_app(configuration)
|
||||
assert "SECRET_KEY_FILE" not in app.config
|
||||
assert app.config["SECRET_KEY"] == "very-secret"
|
||||
del os.environ["SECRETS_DIR"]
|
||||
|
||||
|
||||
def test_configuration_from_environment_vars():
|
||||
os.environ["SECRET_KEY"] = "very-very-secret"
|
||||
os.environ["CANAILLE__SMTP__FROM_ADDR"] = "user@mydomain.tld"
|
||||
os.environ["CANAILLE_OIDC__REQUIRE_NONCE"] = "false"
|
||||
os.environ["CANAILLE_SQL__DATABASE_URI"] = "sqlite:///anything.db"
|
||||
|
||||
conf = settings_factory({"TIMEZONE": "UTC"})
|
||||
assert conf.SECRET_KEY == "very-very-secret"
|
||||
assert conf.CANAILLE.SMTP.FROM_ADDR == "user@mydomain.tld"
|
||||
assert conf.CANAILLE_OIDC.REQUIRE_NONCE is False
|
||||
assert conf.CANAILLE_SQL.DATABASE_URI == "sqlite:///anything.db"
|
||||
|
||||
app = create_app({"TIMEZONE": "UTC"})
|
||||
assert app.config["SECRET_KEY"] == "very-very-secret"
|
||||
assert app.config["CANAILLE"]["SMTP"]["FROM_ADDR"] == "user@mydomain.tld"
|
||||
assert app.config["CANAILLE_OIDC"]["REQUIRE_NONCE"] is False
|
||||
assert app.config["CANAILLE_SQL"]["DATABASE_URI"] == "sqlite:///anything.db"
|
||||
|
||||
del os.environ["SECRET_KEY"]
|
||||
del os.environ["CANAILLE__SMTP__FROM_ADDR"]
|
||||
del os.environ["CANAILLE_OIDC__REQUIRE_NONCE"]
|
||||
del os.environ["CANAILLE_SQL__DATABASE_URI"]
|
||||
|
||||
|
||||
def test_smtp_connection_remote_smtp_unreachable(testclient, backend, configuration):
|
||||
configuration["SMTP"]["HOST"] = "smtp://invalid-smtp.com"
|
||||
configuration["CANAILLE"]["SMTP"]["HOST"] = "smtp://invalid-smtp.com"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Could not connect to the SMTP server",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_smtp_connection_remote_smtp_wrong_credentials(
|
||||
testclient, backend, configuration
|
||||
):
|
||||
configuration["SMTP"]["PASSWORD"] = "invalid-password"
|
||||
configuration["CANAILLE"]["SMTP"]["PASSWORD"] = "invalid-password"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"SMTP authentication failed with user",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_smtp_connection_remote_smtp_no_credentials(testclient, backend, configuration):
|
||||
del configuration["SMTP"]["LOGIN"]
|
||||
del configuration["SMTP"]["PASSWORD"]
|
||||
validate(configuration, validate_remote=True)
|
||||
del configuration["CANAILLE"]["SMTP"]["LOGIN"]
|
||||
del configuration["CANAILLE"]["SMTP"]["PASSWORD"]
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_smtp_bad_tls(testclient, backend, smtpd, configuration):
|
||||
configuration["SMTP"]["TLS"] = False
|
||||
configuration["CANAILLE"]["SMTP"]["TLS"] = False
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"SMTP AUTH extension not supported by server",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def themed_testclient(
|
||||
app,
|
||||
configuration,
|
||||
backend,
|
||||
):
|
||||
def themed_testclient(app, configuration, backend):
|
||||
configuration["TESTING"] = True
|
||||
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
test_theme_path = os.path.join(root, "fixtures", "themes", "test")
|
||||
configuration["THEME"] = test_theme_path
|
||||
configuration["CANAILLE"]["THEME"] = test_theme_path
|
||||
|
||||
app = create_app(configuration)
|
||||
|
||||
|
@ -82,18 +111,25 @@ def test_theme(testclient, themed_testclient, backend):
|
|||
|
||||
|
||||
def test_invalid_theme(configuration, backend):
|
||||
validate(configuration, validate_remote=False)
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
validate(config_dict, validate_remote=False)
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Cannot find theme",
|
||||
):
|
||||
configuration["THEME"] = "invalid"
|
||||
validate(configuration, validate_remote=False)
|
||||
configuration["CANAILLE"]["THEME"] = "invalid"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict, validate_remote=False)
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Cannot find theme",
|
||||
):
|
||||
configuration["THEME"] = "/path/to/invalid"
|
||||
validate(configuration, validate_remote=False)
|
||||
configuration["CANAILLE"]["THEME"] = "/path/to/invalid"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict, validate_remote=False)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -11,7 +11,7 @@ from canaille.app.forms import phone_number
|
|||
|
||||
|
||||
def test_datetime_utc_field_no_timezone_is_local_timezone(testclient):
|
||||
del current_app.config["TIMEZONE"]
|
||||
current_app.config["CANAILLE"]["TIMEZONE"] = None
|
||||
|
||||
class TestForm(wtforms.Form):
|
||||
dt = DateTimeUTCField()
|
||||
|
@ -56,7 +56,7 @@ def test_datetime_utc_field_no_timezone_is_local_timezone(testclient):
|
|||
|
||||
|
||||
def test_datetime_utc_field_utc(testclient):
|
||||
current_app.config["TIMEZONE"] = "UTC"
|
||||
current_app.config["CANAILLE"]["TIMEZONE"] = "UTC"
|
||||
|
||||
class TestForm(wtforms.Form):
|
||||
dt = DateTimeUTCField()
|
||||
|
@ -99,7 +99,7 @@ def test_datetime_utc_field_utc(testclient):
|
|||
|
||||
|
||||
def test_datetime_utc_field_japan_timezone(testclient):
|
||||
current_app.config["TIMEZONE"] = "Japan"
|
||||
current_app.config["CANAILLE"]["TIMEZONE"] = "Japan"
|
||||
|
||||
class TestForm(wtforms.Form):
|
||||
dt = DateTimeUTCField()
|
||||
|
@ -143,7 +143,7 @@ def test_datetime_utc_field_japan_timezone(testclient):
|
|||
|
||||
|
||||
def test_datetime_utc_field_invalid_timezone(testclient):
|
||||
current_app.config["TIMEZONE"] = "invalid"
|
||||
current_app.config["CANAILLE"]["TIMEZONE"] = "invalid"
|
||||
|
||||
class TestForm(wtforms.Form):
|
||||
dt = DateTimeUTCField()
|
||||
|
@ -188,8 +188,8 @@ def test_datetime_utc_field_invalid_timezone(testclient):
|
|||
|
||||
|
||||
def test_fieldlist_add_readonly(testclient, logged_user):
|
||||
testclient.app.config["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
|
||||
testclient.app.config["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
|
||||
logged_user.reload()
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
|
@ -207,8 +207,8 @@ def test_fieldlist_add_readonly(testclient, logged_user):
|
|||
|
||||
|
||||
def test_fieldlist_remove_readonly(testclient, logged_user):
|
||||
testclient.app.config["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
|
||||
testclient.app.config["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["WRITE"].remove("phone_numbers")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].append("phone_numbers")
|
||||
logged_user.reload()
|
||||
|
||||
logged_user.phone_numbers = ["555-555-000", "555-555-111"]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -3,7 +3,7 @@ import pytest
|
|||
|
||||
@pytest.fixture
|
||||
def configuration(ldap_configuration):
|
||||
ldap_configuration["BACKENDS"]["LDAP"]["USER_RDN"] = "mail"
|
||||
ldap_configuration["CANAILLE_LDAP"]["USER_RDN"] = "mail"
|
||||
yield ldap_configuration
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -2,6 +2,7 @@ import pytest
|
|||
from flask_webtest import TestApp
|
||||
|
||||
from canaille import create_app
|
||||
from canaille.app.configuration import settings_factory
|
||||
from canaille.app.installation import InstallationException
|
||||
from canaille.backends.ldap.backend import Backend
|
||||
from canaille.backends.ldap.ldapobject import LDAPObject
|
||||
|
@ -46,61 +47,67 @@ def test_setup_ldap_tree(slapd_server, configuration):
|
|||
|
||||
|
||||
def test_install_schemas(configuration, slapd_server):
|
||||
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = slapd_server.root_dn
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = slapd_server.root_pw
|
||||
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["CANAILLE_LDAP"]["BIND_DN"] = slapd_server.root_dn
|
||||
configuration["CANAILLE_LDAP"]["BIND_PW"] = slapd_server.root_pw
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
Backend.setup_schemas(configuration)
|
||||
Backend.setup_schemas(config_dict)
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
|
||||
def test_install_schemas_twice(configuration, slapd_server):
|
||||
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = slapd_server.root_dn
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = slapd_server.root_pw
|
||||
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["CANAILLE_LDAP"]["BIND_DN"] = slapd_server.root_dn
|
||||
configuration["CANAILLE_LDAP"]["BIND_PW"] = slapd_server.root_pw
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
Backend.setup_schemas(configuration)
|
||||
Backend.setup_schemas(config_dict)
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
Backend.setup_schemas(configuration)
|
||||
Backend.setup_schemas(config_dict)
|
||||
|
||||
|
||||
def test_install_no_permissions_to_install_schemas(configuration, slapd_server):
|
||||
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = (
|
||||
"uid=admin,ou=users,dc=mydomain,dc=tld"
|
||||
)
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = "admin"
|
||||
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["CANAILLE_LDAP"]["BIND_DN"] = "uid=admin,ou=users,dc=mydomain,dc=tld"
|
||||
configuration["CANAILLE_LDAP"]["BIND_PW"] = "admin"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
with pytest.raises(InstallationException):
|
||||
Backend.setup_schemas(configuration)
|
||||
Backend.setup_schemas(config_dict)
|
||||
|
||||
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
|
||||
def test_install_schemas_command(configuration, slapd_server):
|
||||
configuration["BACKENDS"]["LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["BACKENDS"]["LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_DN"] = slapd_server.root_dn
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = slapd_server.root_pw
|
||||
configuration["CANAILLE_LDAP"]["ROOT_DN"] = slapd_server.suffix
|
||||
configuration["CANAILLE_LDAP"]["URI"] = slapd_server.ldap_uri
|
||||
configuration["CANAILLE_LDAP"]["BIND_DN"] = slapd_server.root_dn
|
||||
configuration["CANAILLE_LDAP"]["BIND_PW"] = slapd_server.root_pw
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" not in LDAPObject.ldap_object_classes(force=True)
|
||||
|
||||
testclient = TestApp(create_app(configuration, validate=False))
|
||||
|
@ -108,5 +115,5 @@ def test_install_schemas_command(configuration, slapd_server):
|
|||
res = runner.invoke(cli, ["install"])
|
||||
assert res.exit_code == 0, res.stdout
|
||||
|
||||
with Backend(configuration).session():
|
||||
with Backend(config_dict).session():
|
||||
assert "oauthClient" in LDAPObject.ldap_object_classes(force=True)
|
||||
|
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
|
||||
from canaille.app import models
|
||||
from canaille.app.configuration import ConfigurationException
|
||||
from canaille.app.configuration import settings_factory
|
||||
from canaille.app.configuration import validate
|
||||
from canaille.backends.ldap.backend import setup_ldap_models
|
||||
from canaille.backends.ldap.ldapobject import LDAPObject
|
||||
|
@ -194,7 +195,7 @@ def test_guess_object_from_dn(backend, testclient, foo_group):
|
|||
|
||||
|
||||
def test_object_class_update(backend, testclient):
|
||||
testclient.app.config["BACKENDS"]["LDAP"]["USER_CLASS"] = ["inetOrgPerson"]
|
||||
testclient.app.config["CANAILLE_LDAP"]["USER_CLASS"] = ["inetOrgPerson"]
|
||||
setup_ldap_models(testclient.app.config)
|
||||
|
||||
user1 = models.User(cn="foo1", sn="bar1", user_name="baz1")
|
||||
|
@ -205,7 +206,7 @@ def test_object_class_update(backend, testclient):
|
|||
"inetOrgPerson"
|
||||
]
|
||||
|
||||
testclient.app.config["BACKENDS"]["LDAP"]["USER_CLASS"] = [
|
||||
testclient.app.config["CANAILLE_LDAP"]["USER_CLASS"] = [
|
||||
"inetOrgPerson",
|
||||
"extensibleObject",
|
||||
]
|
||||
|
@ -241,34 +242,47 @@ def test_object_class_update(backend, testclient):
|
|||
|
||||
|
||||
def test_ldap_connection_no_remote(testclient, configuration):
|
||||
validate(configuration)
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict)
|
||||
|
||||
|
||||
def test_ldap_connection_remote(testclient, configuration, backend):
|
||||
validate(configuration, validate_remote=True)
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_ldap_connection_remote_ldap_unreachable(testclient, configuration):
|
||||
configuration["BACKENDS"]["LDAP"]["URI"] = "ldap://invalid-ldap.com"
|
||||
configuration["CANAILLE_LDAP"]["URI"] = "ldap://invalid-ldap.com"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Could not connect to the LDAP server",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_ldap_connection_remote_ldap_wrong_credentials(testclient, configuration):
|
||||
configuration["BACKENDS"]["LDAP"]["BIND_PW"] = "invalid-password"
|
||||
configuration["CANAILLE_LDAP"]["BIND_PW"] = "invalid-password"
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"LDAP authentication failed with user",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_ldap_cannot_create_users(testclient, configuration, backend):
|
||||
from canaille.core.models import User
|
||||
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
def fake_init(*args, **kwarg):
|
||||
raise ldap.INSUFFICIENT_ACCESS
|
||||
|
||||
|
@ -277,12 +291,15 @@ def test_ldap_cannot_create_users(testclient, configuration, backend):
|
|||
ConfigurationException,
|
||||
match=r"cannot create users at",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_ldap_cannot_create_groups(testclient, configuration, backend):
|
||||
from canaille.core.models import Group
|
||||
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
def fake_init(*args, **kwarg):
|
||||
raise ldap.INSUFFICIENT_ACCESS
|
||||
|
||||
|
@ -291,23 +308,23 @@ def test_ldap_cannot_create_groups(testclient, configuration, backend):
|
|||
ConfigurationException,
|
||||
match=r"cannot create groups at",
|
||||
):
|
||||
validate(configuration, validate_remote=True)
|
||||
validate(config_dict, validate_remote=True)
|
||||
|
||||
|
||||
def test_login_placeholder(testclient):
|
||||
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = "(uid={{ login }})"
|
||||
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = "(uid={{ login }})"
|
||||
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
|
||||
assert placeholder == "jdoe"
|
||||
|
||||
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = "(cn={{ login }})"
|
||||
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = "(cn={{ login }})"
|
||||
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
|
||||
assert placeholder == "John Doe"
|
||||
|
||||
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = "(mail={{ login }})"
|
||||
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = "(mail={{ login }})"
|
||||
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
|
||||
assert placeholder == "john@doe.com"
|
||||
|
||||
testclient.app.config["BACKENDS"]["LDAP"]["USER_FILTER"] = (
|
||||
testclient.app.config["CANAILLE_LDAP"]["USER_FILTER"] = (
|
||||
"(|(uid={{ login }})(mail={{ login }}))"
|
||||
)
|
||||
placeholder = testclient.get("/login").form["login"].attrs["placeholder"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -11,7 +11,7 @@ from canaille.core.endpoints.account import RegistrationPayload
|
|||
def test_confirmation_disabled_email_editable(testclient, backend, logged_user):
|
||||
"""If email confirmation is disabled, users should be able to pick any
|
||||
email."""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = False
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
|
@ -36,8 +36,8 @@ def test_confirmation_unset_smtp_disabled_email_editable(
|
|||
"""If email confirmation is unset and no SMTP server has been configured,
|
||||
then email confirmation cannot be enabled, thus users must be able to pick
|
||||
any email."""
|
||||
del testclient.app.config["SMTP"]
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = None
|
||||
del testclient.app.config["CANAILLE"]["SMTP"]
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = None
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
|
@ -61,8 +61,8 @@ def test_confirmation_enabled_smtp_disabled_readonly(testclient, backend, logged
|
|||
|
||||
In doubt, users cannot edit their emails.
|
||||
"""
|
||||
del testclient.app.config["SMTP"]
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = True
|
||||
del testclient.app.config["CANAILLE"]["SMTP"]
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = True
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" in res.forms["emailconfirmationform"]["old_emails-0"].attrs
|
||||
|
@ -77,7 +77,7 @@ def test_confirmation_unset_smtp_enabled_email_admin_editable(
|
|||
):
|
||||
"""Administrators should be able to edit user email addresses, even when
|
||||
email confirmation is unset and SMTP is configured."""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = None
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = None
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
|
@ -100,8 +100,8 @@ def test_confirmation_enabled_smtp_disabled_admin_editable(
|
|||
):
|
||||
"""Administrators should be able to edit user email addresses, even when
|
||||
email confirmation is enabled and SMTP is disabled."""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = True
|
||||
del testclient.app.config["SMTP"]
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = True
|
||||
del testclient.app.config["CANAILLE"]["SMTP"]
|
||||
|
||||
res = testclient.get("/profile/user")
|
||||
assert "readonly" not in res.form["emails-0"].attrs
|
||||
|
@ -124,7 +124,7 @@ def test_confirmation_unset_smtp_enabled_email_user_validation(
|
|||
):
|
||||
"""If email confirmation is unset and there is a SMTP server configured,
|
||||
then users emails should be validated by sending a confirmation email."""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = None
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = None
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 01:00:00"):
|
||||
res = testclient.get("/login")
|
||||
|
@ -165,9 +165,8 @@ def test_confirmation_unset_smtp_enabled_email_user_validation(
|
|||
)
|
||||
|
||||
assert len(smtpd.messages) == 1
|
||||
assert email_confirmation_url in str(smtpd.messages[0].get_payload()[0]).replace(
|
||||
"=\n", ""
|
||||
)
|
||||
email_content = str(smtpd.messages[0].get_payload()[0]).replace("=\n", "")
|
||||
assert email_confirmation_url in email_content
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 03:00:00"):
|
||||
res = testclient.get(email_confirmation_url)
|
||||
|
@ -455,7 +454,7 @@ def test_edition_forced_mail(testclient, logged_user):
|
|||
def test_invitation_form_mail_field_readonly(testclient):
|
||||
"""Tests that the email field is readonly in the invitation form creation
|
||||
if email confirmation is enabled."""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = True
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = True
|
||||
|
||||
payload = RegistrationPayload(
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
|
@ -474,7 +473,7 @@ def test_invitation_form_mail_field_readonly(testclient):
|
|||
def test_invitation_form_mail_field_writable(testclient):
|
||||
"""Tests that the email field is writable in the invitation form creation
|
||||
if email confirmation is disabled."""
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = False
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
|
||||
|
||||
payload = RegistrationPayload(
|
||||
datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
|
|
|
@ -2,7 +2,7 @@ from unittest import mock
|
|||
|
||||
|
||||
def test_password_forgotten_disabled(smtpd, testclient, user):
|
||||
testclient.app.config["ENABLE_PASSWORD_RECOVERY"] = False
|
||||
testclient.app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"] = False
|
||||
|
||||
testclient.get("/reset", status=404)
|
||||
testclient.get("/reset/user/hash", status=404)
|
||||
|
@ -54,7 +54,7 @@ def test_password_forgotten_invalid_form(smtpd, testclient, user):
|
|||
|
||||
|
||||
def test_password_forgotten_invalid(smtpd, testclient, user):
|
||||
testclient.app.config["HIDE_INVALID_LOGINS"] = True
|
||||
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = True
|
||||
res = testclient.get("/reset", status=200)
|
||||
|
||||
res.form["login"] = "i-dont-really-exist"
|
||||
|
@ -65,7 +65,7 @@ def test_password_forgotten_invalid(smtpd, testclient, user):
|
|||
) in res.flashes
|
||||
res.mustcontain(no="The login '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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -7,7 +7,7 @@ from canaille.core.populate import fake_users
|
|||
|
||||
@pytest.fixture
|
||||
def configuration(configuration):
|
||||
configuration["EMAIL_CONFIRMATION"] = False
|
||||
configuration["CANAILLE"]["EMAIL_CONFIRMATION"] = False
|
||||
return configuration
|
||||
|
||||
|
||||
|
@ -86,7 +86,8 @@ def test_user_list_search_only_allowed_fields(
|
|||
res.mustcontain(user.formatted_name)
|
||||
res.mustcontain(no=moderator.formatted_name)
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"]["READ"].remove("user_name")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].remove("user_name")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["READ"].remove("user_name")
|
||||
g.user.reload()
|
||||
|
||||
form = res.forms["search"]
|
||||
|
@ -103,11 +104,11 @@ def test_edition_permission(
|
|||
logged_user,
|
||||
admin,
|
||||
):
|
||||
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = []
|
||||
logged_user.reload()
|
||||
testclient.get("/profile/user", status=404)
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["PERMISSIONS"] = ["edit_self"]
|
||||
g.user.reload()
|
||||
testclient.get("/profile/user", status=200)
|
||||
|
||||
|
@ -202,7 +203,7 @@ def test_field_permissions_none(testclient, logged_user):
|
|||
logged_user.phone_numbers = ["555-666-777"]
|
||||
logged_user.save()
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"] = {
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"] = {
|
||||
"READ": ["user_name"],
|
||||
"WRITE": [],
|
||||
"PERMISSIONS": ["edit_self"],
|
||||
|
@ -230,7 +231,7 @@ def test_field_permissions_read(testclient, logged_user):
|
|||
logged_user.phone_numbers = ["555-666-777"]
|
||||
logged_user.save()
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"] = {
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"] = {
|
||||
"READ": ["user_name", "phone_numbers"],
|
||||
"WRITE": [],
|
||||
"PERMISSIONS": ["edit_self"],
|
||||
|
@ -257,7 +258,7 @@ def test_field_permissions_write(testclient, logged_user):
|
|||
logged_user.phone_numbers = ["555-666-777"]
|
||||
logged_user.save()
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"] = {
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"] = {
|
||||
"READ": ["user_name"],
|
||||
"WRITE": ["phone_numbers"],
|
||||
"PERMISSIONS": ["edit_self"],
|
||||
|
@ -386,9 +387,9 @@ def test_inline_validation(testclient, logged_admin, user):
|
|||
|
||||
|
||||
def test_inline_validation_keep_indicators(testclient, logged_admin, user):
|
||||
testclient.app.config["ACL"]["DEFAULT"]["WRITE"].remove("display_name")
|
||||
testclient.app.config["ACL"]["DEFAULT"]["READ"].append("display_name")
|
||||
testclient.app.config["ACL"]["ADMIN"]["WRITE"].append("display_name")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["WRITE"].remove("display_name")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["DEFAULT"]["READ"].append("display_name")
|
||||
testclient.app.config["CANAILLE"]["ACL"]["ADMIN"]["WRITE"].append("display_name")
|
||||
logged_admin.reload()
|
||||
user.reload()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -9,8 +9,8 @@ from canaille.core.endpoints.account import RegistrationPayload
|
|||
|
||||
def test_registration_without_email_validation(testclient, backend, foo_group):
|
||||
"""Tests a nominal registration without email validation."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = False
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
|
||||
|
||||
assert not models.User.query(user_name="newuser")
|
||||
res = testclient.get(url_for("core.account.registration"), status=200)
|
||||
|
@ -29,7 +29,7 @@ def test_registration_without_email_validation(testclient, backend, foo_group):
|
|||
|
||||
def test_registration_with_email_validation(testclient, backend, smtpd, foo_group):
|
||||
"""Tests a nominal registration with email validation."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
|
||||
with freezegun.freeze_time("2020-01-01 02:00:00"):
|
||||
res = testclient.get(url_for("core.account.join"))
|
||||
|
@ -82,9 +82,9 @@ def test_registration_with_email_already_taken(
|
|||
testclient, backend, smtpd, user, foo_group
|
||||
):
|
||||
"""Be sure to not leak email existence if HIDE_INVALID_LOGINS is true."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
|
||||
testclient.app.config["HIDE_INVALID_LOGINS"] = True
|
||||
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = True
|
||||
res = testclient.get(url_for("core.account.join"))
|
||||
res.form["email"] = "john@doe.com"
|
||||
res = res.form.submit()
|
||||
|
@ -95,7 +95,7 @@ def test_registration_with_email_already_taken(
|
|||
)
|
||||
]
|
||||
|
||||
testclient.app.config["HIDE_INVALID_LOGINS"] = False
|
||||
testclient.app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] = False
|
||||
res = testclient.get(url_for("core.account.join"))
|
||||
res.form["email"] = "john@doe.com"
|
||||
res = res.form.submit()
|
||||
|
@ -107,35 +107,35 @@ def test_registration_with_email_validation_needs_a_valid_link(
|
|||
testclient, backend, smtpd, foo_group
|
||||
):
|
||||
"""Tests a nominal registration without email validation."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
testclient.get(url_for("core.account.registration"), status=403)
|
||||
|
||||
|
||||
def test_join_page_registration_disabled(testclient, backend, smtpd, foo_group):
|
||||
"""The join page should not be available if registration is disabled."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = False
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = False
|
||||
testclient.get(url_for("core.account.join"), status=404)
|
||||
|
||||
|
||||
def test_join_page_email_confirmation_disabled(testclient, backend, smtpd, foo_group):
|
||||
"""The join page should directly redirect to the registration page if email
|
||||
confirmation is disabled."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["EMAIL_CONFIRMATION"] = False
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
|
||||
res = testclient.get(url_for("core.account.join"), status=302)
|
||||
assert res.location == url_for("core.account.registration")
|
||||
|
||||
|
||||
def test_join_page_already_logged_in(testclient, backend, logged_user, foo_group):
|
||||
"""The join page should not be accessible for logged users."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
testclient.get(url_for("core.account.join"), status=403)
|
||||
|
||||
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_registration_mail_error(SMTP, testclient, backend, smtpd, foo_group):
|
||||
"""Display an error message if the registration mail could not be sent."""
|
||||
testclient.app.config["ENABLE_REGISTRATION"] = True
|
||||
testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
|
||||
SMTP.side_effect = mock.Mock(side_effect=OSError("unit test mail error"))
|
||||
res = testclient.get(url_for("core.account.join"))
|
||||
res.form["email"] = "foo@bar.com"
|
||||
|
|
|
@ -2,13 +2,13 @@ from canaille.oidc.installation import install
|
|||
|
||||
|
||||
def test_install_keypair(configuration):
|
||||
del configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
|
||||
del configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
del configuration["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"]
|
||||
del configuration["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
|
||||
install(configuration, debug=False)
|
||||
assert "PRIVATE_KEY" not in configuration["OIDC"]["JWT"]
|
||||
assert "PUBLIC_KEY" not in configuration["OIDC"]["JWT"]
|
||||
assert "PRIVATE_KEY" not in configuration["CANAILLE_OIDC"]["JWT"]
|
||||
assert "PUBLIC_KEY" not in configuration["CANAILLE_OIDC"]["JWT"]
|
||||
|
||||
install(configuration, debug=True)
|
||||
assert configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
|
||||
assert configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
assert configuration["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"]
|
||||
assert configuration["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -3,18 +3,21 @@ import warnings
|
|||
import pytest
|
||||
|
||||
from canaille.app.configuration import ConfigurationException
|
||||
from canaille.app.configuration import settings_factory
|
||||
from canaille.app.configuration import validate
|
||||
from canaille.oidc.oauth import get_issuer
|
||||
|
||||
|
||||
def test_issuer(testclient):
|
||||
with warnings.catch_warnings(record=True):
|
||||
testclient.app.config["OIDC"]["JWT"]["ISS"] = "https://anyauth.mydomain.tld"
|
||||
testclient.app.config["CANAILLE_OIDC"]["JWT"]["ISS"] = (
|
||||
"https://anyauth.mydomain.tld"
|
||||
)
|
||||
testclient.app.config["SERVER_NAME"] = "https://otherauth.mydomain.tld"
|
||||
with testclient.app.test_request_context("/"):
|
||||
assert get_issuer() == "https://anyauth.mydomain.tld"
|
||||
|
||||
del testclient.app.config["OIDC"]["JWT"]["ISS"]
|
||||
testclient.app.config["CANAILLE_OIDC"]["JWT"]["ISS"] = None
|
||||
with testclient.app.test_request_context("/"):
|
||||
assert get_issuer() == "https://otherauth.mydomain.tld"
|
||||
|
||||
|
@ -24,18 +27,24 @@ def test_issuer(testclient):
|
|||
|
||||
|
||||
def test_no_private_key(testclient, configuration):
|
||||
del configuration["OIDC"]["JWT"]["PRIVATE_KEY"]
|
||||
del configuration["CANAILLE_OIDC"]["JWT"]["PRIVATE_KEY"]
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"No private key has been set",
|
||||
):
|
||||
validate(configuration)
|
||||
validate(config_dict)
|
||||
|
||||
|
||||
def test_no_public_key(testclient, configuration):
|
||||
del configuration["OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
del configuration["CANAILLE_OIDC"]["JWT"]["PUBLIC_KEY"]
|
||||
config_obj = settings_factory(configuration)
|
||||
config_dict = config_obj.model_dump()
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"No public key has been set",
|
||||
):
|
||||
validate(configuration)
|
||||
validate(config_dict)
|
||||
|
|
|
@ -8,10 +8,10 @@ from canaille.app import models
|
|||
def test_client_registration_with_authentication_static_token(
|
||||
testclient, backend, client, user
|
||||
):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
"static-token"
|
||||
]
|
||||
|
||||
|
@ -70,7 +70,7 @@ def test_client_registration_with_authentication_static_token(
|
|||
def test_client_registration_with_authentication_no_token(
|
||||
testclient, backend, client, user
|
||||
):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
|
||||
|
@ -104,7 +104,7 @@ def test_client_registration_with_authentication_no_token(
|
|||
def test_client_registration_with_authentication_invalid_token(
|
||||
testclient, backend, client, user
|
||||
):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
|
||||
|
@ -130,7 +130,7 @@ def test_client_registration_with_authentication_invalid_token(
|
|||
|
||||
def test_client_registration_with_software_statement(testclient, backend, keypair):
|
||||
private_key, _ = keypair
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
|
||||
|
||||
software_statement_payload = {
|
||||
"software_id": "4NRB1-0XZABZI9E6-5SM3R",
|
||||
|
@ -181,7 +181,7 @@ def test_client_registration_with_software_statement(testclient, backend, keypai
|
|||
|
||||
|
||||
def test_client_registration_without_authentication_ok(testclient, backend):
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_OPEN"] = True
|
||||
|
||||
payload = {
|
||||
"redirect_uris": [
|
||||
|
|
|
@ -5,10 +5,10 @@ from canaille.app import models
|
|||
|
||||
|
||||
def test_get(testclient, backend, client, user):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
"static-token"
|
||||
]
|
||||
|
||||
|
@ -51,10 +51,10 @@ def test_get(testclient, backend, client, user):
|
|||
|
||||
|
||||
def test_update(testclient, backend, client, user):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
"static-token"
|
||||
]
|
||||
|
||||
|
@ -138,10 +138,10 @@ def test_update(testclient, backend, client, user):
|
|||
|
||||
|
||||
def test_delete(testclient, backend, user):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
"static-token"
|
||||
]
|
||||
|
||||
|
@ -157,10 +157,10 @@ def test_delete(testclient, backend, user):
|
|||
|
||||
|
||||
def test_invalid_client(testclient, backend, user):
|
||||
assert not testclient.app.config.get("OIDC", {}).get(
|
||||
assert not testclient.app.config["CANAILLE_OIDC"].get(
|
||||
"DYNAMIC_CLIENT_REGISTRATION_OPEN"
|
||||
)
|
||||
testclient.app.config["OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
testclient.app.config["CANAILLE_OIDC"]["DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = [
|
||||
"static-token"
|
||||
]
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
from canaille.oidc.oauth import DEFAULT_JWT_MAPPING
|
||||
from canaille.oidc.configuration import JWTSettings
|
||||
from canaille.oidc.oauth import claims_from_scope
|
||||
from canaille.oidc.oauth import generate_user_claims
|
||||
|
||||
|
@ -267,7 +267,8 @@ STANDARD_CLAIMS = [
|
|||
def test_generate_user_standard_claims_with_default_config(testclient, backend, user):
|
||||
user.preferred_language = "fr"
|
||||
|
||||
data = generate_user_claims(user, STANDARD_CLAIMS, DEFAULT_JWT_MAPPING)
|
||||
default_jwt_mapping = JWTSettings().model_dump()
|
||||
data = generate_user_claims(user, STANDARD_CLAIMS, default_jwt_mapping)
|
||||
|
||||
assert data == {
|
||||
"address": "1235, somewhere",
|
||||
|
@ -283,7 +284,7 @@ def test_generate_user_standard_claims_with_default_config(testclient, backend,
|
|||
|
||||
|
||||
def test_custom_config_format_claim_is_well_formated(testclient, backend, user):
|
||||
jwt_mapping_config = DEFAULT_JWT_MAPPING.copy()
|
||||
jwt_mapping_config = JWTSettings().model_dump()
|
||||
jwt_mapping_config["EMAIL"] = "{{ user.user_name }}@mydomain.tld"
|
||||
|
||||
data = generate_user_claims(user, STANDARD_CLAIMS, jwt_mapping_config)
|
||||
|
@ -297,6 +298,7 @@ def test_claim_is_omitted_if_empty(testclient, backend, user):
|
|||
user.emails = []
|
||||
user.save()
|
||||
|
||||
data = generate_user_claims(user, STANDARD_CLAIMS, DEFAULT_JWT_MAPPING)
|
||||
default_jwt_mapping = JWTSettings().model_dump()
|
||||
data = generate_user_claims(user, STANDARD_CLAIMS, default_jwt_mapping)
|
||||
|
||||
assert "email" not in data
|
||||
|
|
|
@ -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"]
|
||||
|
|
Loading…
Reference in a new issue