forked from Github-Mirrors/canaille
Permissions overhaul
This commit is contained in:
parent
6dc401e170
commit
d2611abadb
19 changed files with 348 additions and 196 deletions
|
@ -23,7 +23,7 @@ from .forms import (
|
|||
ForgottenPasswordForm,
|
||||
profile_form,
|
||||
)
|
||||
from .flaskutils import current_user, user_needed, moderator_needed, admin_needed
|
||||
from .flaskutils import current_user, user_needed, permissions_needed
|
||||
from .mails import (
|
||||
send_password_initialization_mail,
|
||||
send_invitation_mail,
|
||||
|
@ -140,14 +140,14 @@ def firstlogin(uid):
|
|||
|
||||
|
||||
@bp.route("/users")
|
||||
@moderator_needed()
|
||||
@permissions_needed("manage_users")
|
||||
def users(user):
|
||||
users = User.filter(objectClass=current_app.config["LDAP"]["USER_CLASS"])
|
||||
return render_template("users.html", users=users, menuitem="users")
|
||||
|
||||
|
||||
@bp.route("/invite", methods=["GET", "POST"])
|
||||
@moderator_needed()
|
||||
@permissions_needed("manage_users")
|
||||
def user_invitation(user):
|
||||
form = InvitationForm(request.form or None)
|
||||
|
||||
|
@ -175,15 +175,10 @@ def user_invitation(user):
|
|||
|
||||
|
||||
@bp.route("/profile", methods=("GET", "POST"))
|
||||
@moderator_needed()
|
||||
@permissions_needed("manage_users")
|
||||
def profile_creation(user):
|
||||
form = profile_form(current_app.config["LDAP"]["FIELDS"])
|
||||
form = profile_form(user.write, user.read)
|
||||
form.process(CombinedMultiDict((request.files, request.form)) or None)
|
||||
try:
|
||||
if "uid" in form:
|
||||
del form["uid"].render_kw["readonly"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if request.form:
|
||||
if not form.validate():
|
||||
|
@ -240,13 +235,11 @@ def registration(data, hash):
|
|||
"groups": data[2],
|
||||
}
|
||||
|
||||
form = profile_form(current_app.config["LDAP"]["FIELDS"])
|
||||
readable_fields = set(current_app.config["ACL"]["DEFAULT"]["READ"])
|
||||
writable_fields = set(current_app.config["ACL"]["DEFAULT"]["WRITE"])
|
||||
|
||||
form = profile_form(writable_fields, readable_fields)
|
||||
form.process(CombinedMultiDict((request.files, request.form)) or None, data=data)
|
||||
try:
|
||||
if "uid" in form:
|
||||
del form["uid"].render_kw["readonly"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
form["password1"].validators = [
|
||||
wtforms.validators.DataRequired(),
|
||||
|
@ -305,7 +298,7 @@ def profile_create(current_app, form):
|
|||
@bp.route("/profile/<username>", methods=("GET", "POST"))
|
||||
@user_needed()
|
||||
def profile_edition(user, username):
|
||||
user.moderator or username == user.uid[0] or abort(403)
|
||||
user.can_manage_users or username == user.uid[0] or abort(403)
|
||||
|
||||
if request.method == "GET" or request.form.get("action") == "edit":
|
||||
return profile_edit(user, username)
|
||||
|
@ -346,7 +339,7 @@ def profile_edition(user, username):
|
|||
|
||||
def profile_edit(editor, username):
|
||||
menuitem = "profile" if username == editor.uid[0] else "users"
|
||||
fields = current_app.config["LDAP"]["FIELDS"]
|
||||
fields = editor.read | editor.write
|
||||
if username != editor.uid[0]:
|
||||
user = User.get(username) or abort(404)
|
||||
else:
|
||||
|
@ -363,11 +356,8 @@ def profile_edit(editor, username):
|
|||
if "groups" in fields:
|
||||
data["groups"] = [g.dn for g in user.groups]
|
||||
|
||||
form = profile_form(fields)
|
||||
form = profile_form(editor.write, editor.read)
|
||||
form.process(CombinedMultiDict((request.files, request.form)) or None, data=data)
|
||||
form["uid"].render_kw["readonly"] = "true"
|
||||
if "groups" in form and not editor.admin and not editor.moderator:
|
||||
form["groups"].render_kw["disabled"] = "true"
|
||||
|
||||
if request.form:
|
||||
if not form.validate():
|
||||
|
@ -377,7 +367,7 @@ def profile_edit(editor, username):
|
|||
for attribute in form:
|
||||
if (
|
||||
attribute.name in user.may + user.must
|
||||
and not attribute.name == "uid"
|
||||
and attribute.name in editor.write
|
||||
):
|
||||
if isinstance(attribute.data, FileStorage):
|
||||
data = attribute.data.stream.read()
|
||||
|
@ -388,13 +378,16 @@ def profile_edit(editor, username):
|
|||
user[attribute.name] = data
|
||||
else:
|
||||
user[attribute.name] = [data]
|
||||
elif attribute.name == "groups" and (editor.admin or editor.moderator):
|
||||
elif attribute.name == "groups" and "groups" in editor.write:
|
||||
user.set_groups(attribute.data)
|
||||
|
||||
if (
|
||||
not form["password1"].data or user.set_password(form["password1"].data)
|
||||
"password1" not in request.form
|
||||
or not form["password1"].data
|
||||
or user.set_password(form["password1"].data)
|
||||
) and request.form["action"] == "edit":
|
||||
flash(_("Profile updated successfuly."), "success")
|
||||
|
||||
user.save()
|
||||
|
||||
return render_template(
|
||||
|
@ -402,7 +395,7 @@ def profile_edit(editor, username):
|
|||
form=form,
|
||||
menuitem=menuitem,
|
||||
edited_user=user,
|
||||
self_deletion=current_app.config.get("SELF_DELETION", True),
|
||||
self_deletion=user.can_delete_account,
|
||||
)
|
||||
|
||||
|
||||
|
@ -422,7 +415,7 @@ def profile_delete(user, username):
|
|||
|
||||
|
||||
@bp.route("/impersonate/<username>")
|
||||
@admin_needed()
|
||||
@permissions_needed("impersonate_users")
|
||||
def impersonate(user, username):
|
||||
u = User.get(username) or abort(404)
|
||||
u.login()
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
from flask import Blueprint
|
||||
from flask_themer import render_template
|
||||
from canaille.models import AuthorizationCode
|
||||
from canaille.flaskutils import admin_needed
|
||||
from canaille.flaskutils import permissions_needed
|
||||
|
||||
|
||||
bp = Blueprint("admin_authorizations", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def index(user):
|
||||
authorizations = AuthorizationCode.filter()
|
||||
return render_template(
|
||||
|
@ -17,7 +17,7 @@ def index(user):
|
|||
|
||||
|
||||
@bp.route("/<authorization_id>", methods=["GET", "POST"])
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def view(user, authorization_id):
|
||||
authorization = AuthorizationCode.get(authorization_id)
|
||||
return render_template(
|
||||
|
|
|
@ -6,14 +6,14 @@ from flask_wtf import FlaskForm
|
|||
from flask_babel import lazy_gettext as _
|
||||
from werkzeug.security import gen_salt
|
||||
from canaille.models import Client
|
||||
from canaille.flaskutils import admin_needed
|
||||
from canaille.flaskutils import permissions_needed
|
||||
|
||||
|
||||
bp = Blueprint("admin_clients", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def index(user):
|
||||
clients = Client.filter()
|
||||
return render_template("admin/client_list.html", clients=clients, menuitem="admin")
|
||||
|
@ -127,7 +127,7 @@ class ClientAdd(FlaskForm):
|
|||
|
||||
|
||||
@bp.route("/add", methods=["GET", "POST"])
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def add(user):
|
||||
form = ClientAdd(request.form or None)
|
||||
|
||||
|
@ -177,7 +177,7 @@ def add(user):
|
|||
|
||||
|
||||
@bp.route("/edit/<client_id>", methods=["GET", "POST"])
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def edit(user, client_id):
|
||||
if request.method == "GET" or request.form.get("action") == "edit":
|
||||
return client_edit(client_id)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from flask import Blueprint, current_app, url_for
|
||||
from flask_themer import render_template
|
||||
from flask_babel import gettext as _
|
||||
from canaille.flaskutils import admin_needed
|
||||
from canaille.flaskutils import permissions_needed
|
||||
from canaille.mails import profile_hash
|
||||
from canaille.apputils import obj_to_b64
|
||||
|
||||
|
@ -10,13 +10,13 @@ bp = Blueprint("admin_mails", __name__)
|
|||
|
||||
|
||||
@bp.route("/")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def mail_index(user):
|
||||
return render_template("admin/mails.html")
|
||||
|
||||
|
||||
@bp.route("/password-init.html")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def password_init_html(user):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
reset_url = url_for(
|
||||
|
@ -39,7 +39,7 @@ def password_init_html(user):
|
|||
|
||||
|
||||
@bp.route("/password-init.txt")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def password_init_txt(user):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
reset_url = url_for(
|
||||
|
@ -58,7 +58,7 @@ def password_init_txt(user):
|
|||
|
||||
|
||||
@bp.route("/reset.html")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def password_reset_html(user):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
reset_url = url_for(
|
||||
|
@ -81,7 +81,7 @@ def password_reset_html(user):
|
|||
|
||||
|
||||
@bp.route("/reset.txt")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def password_reset_txt(user):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
reset_url = url_for(
|
||||
|
@ -100,7 +100,7 @@ def password_reset_txt(user):
|
|||
|
||||
|
||||
@bp.route("/<uid>/<email>/invitation.html")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def invitation_html(user, uid, email):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
registration_url = url_for(
|
||||
|
@ -123,7 +123,7 @@ def invitation_html(user, uid, email):
|
|||
|
||||
|
||||
@bp.route("/<uid>/<email>/invitation.txt")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def invitation_txt(user, uid, email):
|
||||
base_url = url_for("account.index", _external=True)
|
||||
registration_url = url_for(
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
from flask import Blueprint
|
||||
from flask_themer import render_template
|
||||
from canaille.models import Token
|
||||
from canaille.flaskutils import admin_needed
|
||||
from canaille.flaskutils import permissions_needed
|
||||
|
||||
|
||||
bp = Blueprint("admin_tokens", __name__)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def index(user):
|
||||
tokens = Token.filter()
|
||||
return render_template("admin/token_list.html", tokens=tokens, menuitem="admin")
|
||||
|
||||
|
||||
@bp.route("/<token_id>", methods=["GET", "POST"])
|
||||
@admin_needed()
|
||||
@permissions_needed("manage_oidc")
|
||||
def view(user, token_id):
|
||||
token = Token.get(token_id)
|
||||
return render_template("admin/token_view.html", token=token, menuitem="admin")
|
||||
|
|
|
@ -34,16 +34,12 @@ OIDC_METADATA_FILE = "canaille/conf/openid-configuration.json"
|
|||
# SENTRY_DSN = "https://examplePublicKey@o0.ingest.sentry.io/0"
|
||||
|
||||
# If this option is set to true, when a user tries to sign in with
|
||||
# an invalid login, a message is shown saying that the login does not
|
||||
# an invalid login, a message is shown indicating that the login does not
|
||||
# exist. If this option is set to false (the default) a message is
|
||||
# shown saying that the password is wrong, but does not give a clue
|
||||
# shown indicating that the password is wrong, but does not give a clue
|
||||
# wether the login exists or not.
|
||||
# HIDE_INVALID_LOGINS = false
|
||||
|
||||
# SELF_DELETION controls the ability for a user to delete his own
|
||||
# account. The default value is true.
|
||||
# SELF_DELETION = true
|
||||
|
||||
[LOGGING]
|
||||
# LEVEL can be one value among:
|
||||
# DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
|
@ -68,39 +64,60 @@ USER_BASE = "ou=users,dc=mydomain,dc=tld"
|
|||
# USER_FILTER = "(|(uid={login})(mail={login}))"
|
||||
USER_FILTER = "(|(uid={login})(cn={login}))"
|
||||
|
||||
# A class to use for creating new users
|
||||
# The object class to use for creating new users
|
||||
USER_CLASS = "inetOrgPerson"
|
||||
|
||||
# Filter to match super admin users. Super admins can manage
|
||||
# OAuth clients, tokens and authorizations. If your LDAP server has
|
||||
# the 'memberof' overlay, you can filter against group membership.
|
||||
# ADMIN_FILTER = "uid=admin"
|
||||
ADMIN_FILTER = "memberof=cn=admins,ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# Filter to match super admin users. User admins can edit, create
|
||||
# and delete user accounts. If your LDAP server has the 'memberof'
|
||||
# overlay, you can filter against group membership.
|
||||
# USER_ADMIN_FILTER = "uid=moderator"
|
||||
USER_ADMIN_FILTER = "memberof=cn=moderators,ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# Where to search for groups?
|
||||
GROUP_BASE = "ou=groups"
|
||||
|
||||
# The object class to use for creating new groups
|
||||
GROUP_CLASS = "groupOfNames"
|
||||
|
||||
# The attribute to use to identify a group
|
||||
GROUP_NAME_ATTRIBUTE = "cn"
|
||||
|
||||
# A filter to check if a user belongs to a group
|
||||
GROUP_USER_FILTER = "(member={user.dn})"
|
||||
|
||||
# The list of ldap fields you want to be editable by the
|
||||
# users.
|
||||
FIELDS = [
|
||||
"uid",
|
||||
"mail",
|
||||
"givenName",
|
||||
"sn",
|
||||
"userPassword",
|
||||
"telephoneNumber",
|
||||
"employeeNumber",
|
||||
# "jpegPhoto",
|
||||
"groups",
|
||||
# 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.
|
||||
#
|
||||
# A 'FILTER' parameter that is a LDAP filter used to determine if a user
|
||||
# belongs to an access control. If absent, all the users will match this
|
||||
# access control. If your LDAP server has the 'memberof' overlay, you can
|
||||
# filter against group membership.
|
||||
# Here are some examples
|
||||
# FILTER = 'uid=admin'
|
||||
# FILTER = 'memberof=cn=admins,ou=groups,dc=mydomain,dc=tld'
|
||||
#
|
||||
# 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:
|
||||
# - "manage_users" to allow other users management
|
||||
# - "manage_groups" to allow group edition and creation
|
||||
# - "manage_oidc" to allow OpenID Connect client managements
|
||||
# - "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]
|
||||
READ = ["uid", "groups"]
|
||||
WRITE = ["givenName", "sn", "userPassword", "telephoneNumber"]
|
||||
|
||||
[ACL.ADMIN]
|
||||
FILTER = "memberof=cn=moderators,ou=groups,dc=mydomain,dc=tld"
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
"manage_groups",
|
||||
"manage_oidc",
|
||||
"delete_account",
|
||||
"impersonate_users",
|
||||
]
|
||||
READ = ["uid"]
|
||||
WRITE = ["groups", "givenName", "sn", "userPassword"]
|
||||
|
||||
# The jwt configuration. You can generate a RSA keypair with:
|
||||
# openssl genrsa -out private.pem 4096
|
||||
|
|
|
@ -37,26 +37,14 @@ def user_needed():
|
|||
return wrapper
|
||||
|
||||
|
||||
def moderator_needed():
|
||||
def permissions_needed(*args):
|
||||
permissions = set(args)
|
||||
|
||||
def wrapper(view_function):
|
||||
@wraps(view_function)
|
||||
def decorator(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user or not user.moderator:
|
||||
abort(403)
|
||||
return view_function(*args, user=user, **kwargs)
|
||||
|
||||
return decorator
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def admin_needed():
|
||||
def wrapper(view_function):
|
||||
@wraps(view_function)
|
||||
def decorator(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user or not user.admin:
|
||||
if not user or not permissions.issubset(user.permissions):
|
||||
abort(403)
|
||||
return view_function(*args, user=user, **kwargs)
|
||||
|
||||
|
|
|
@ -144,21 +144,35 @@ PROFILE_FORM_FIELDS = dict(
|
|||
)
|
||||
|
||||
|
||||
def profile_form(field_names):
|
||||
if "userPassword" in field_names:
|
||||
field_names += ["password1", "password2"]
|
||||
def profile_form(write_field_names, readonly_field_names):
|
||||
if "userPassword" in write_field_names:
|
||||
write_field_names |= {"password1", "password2"}
|
||||
|
||||
fields = {
|
||||
name: PROFILE_FORM_FIELDS.get(name)
|
||||
for name in field_names
|
||||
for name in write_field_names | readonly_field_names
|
||||
if PROFILE_FORM_FIELDS.get(name)
|
||||
}
|
||||
if "groups" in field_names and Group.available_groups():
|
||||
|
||||
if (
|
||||
"groups" in write_field_names | readonly_field_names
|
||||
and Group.available_groups()
|
||||
):
|
||||
fields["groups"] = wtforms.SelectMultipleField(
|
||||
_("Groups"),
|
||||
choices=[(group[1], group[0]) for group in Group.available_groups()],
|
||||
render_kw={},
|
||||
)
|
||||
return wtforms.form.BaseForm(fields)
|
||||
|
||||
form = wtforms.form.BaseForm(fields)
|
||||
for field in form:
|
||||
if field.name in readonly_field_names - write_field_names:
|
||||
if field.name == "groups":
|
||||
field.render_kw["disabled"] = "true"
|
||||
else:
|
||||
field.render_kw["readonly"] = "true"
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class GroupForm(FlaskForm):
|
||||
|
|
|
@ -10,7 +10,7 @@ from flask import (
|
|||
from flask_babel import gettext as _
|
||||
from flask_themer import render_template
|
||||
|
||||
from .flaskutils import moderator_needed
|
||||
from .flaskutils import permissions_needed
|
||||
from .forms import GroupForm
|
||||
from .models import Group
|
||||
|
||||
|
@ -18,14 +18,14 @@ bp = Blueprint("groups", __name__)
|
|||
|
||||
|
||||
@bp.route("/")
|
||||
@moderator_needed()
|
||||
@permissions_needed("manage_groups")
|
||||
def groups(user):
|
||||
groups = Group.filter(objectClass=current_app.config["LDAP"]["GROUP_CLASS"])
|
||||
return render_template("groups.html", groups=groups, menuitem="groups")
|
||||
|
||||
|
||||
@bp.route("/add", methods=("GET", "POST"))
|
||||
@moderator_needed()
|
||||
@permissions_needed("manage_groups")
|
||||
def create_group(user):
|
||||
form = GroupForm(request.form or None)
|
||||
try:
|
||||
|
@ -52,7 +52,7 @@ def create_group(user):
|
|||
|
||||
|
||||
@bp.route("/<groupname>", methods=("GET", "POST"))
|
||||
@moderator_needed()
|
||||
@permissions_needed("manage_groups")
|
||||
def group(user, groupname):
|
||||
group = Group.get(groupname) or abort(404)
|
||||
|
||||
|
|
|
@ -13,10 +13,14 @@ from .ldaputils import LDAPObject
|
|||
|
||||
class User(LDAPObject):
|
||||
id = "cn"
|
||||
admin = False
|
||||
moderator = False
|
||||
_groups = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.read = set()
|
||||
self.write = set()
|
||||
self.permissions = set()
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def get(cls, login=None, dn=None, filter=None, conn=None):
|
||||
conn = conn or cls.ldap()
|
||||
|
@ -25,29 +29,8 @@ class User(LDAPObject):
|
|||
filter = current_app.config["LDAP"].get("USER_FILTER").format(login=login)
|
||||
|
||||
user = super().get(dn, filter, conn)
|
||||
|
||||
admin_filter = current_app.config["LDAP"].get("ADMIN_FILTER")
|
||||
moderator_filter = current_app.config["LDAP"].get("USER_ADMIN_FILTER")
|
||||
if (
|
||||
admin_filter
|
||||
and user
|
||||
and user.dn
|
||||
and conn.search_s(user.dn, ldap.SCOPE_SUBTREE, admin_filter)
|
||||
):
|
||||
|
||||
user.admin = True
|
||||
user.moderator = True
|
||||
|
||||
elif (
|
||||
moderator_filter
|
||||
and user
|
||||
and user.dn
|
||||
and conn.search_s(user.dn, ldap.SCOPE_SUBTREE, moderator_filter)
|
||||
):
|
||||
|
||||
user.moderator = True
|
||||
|
||||
if user:
|
||||
user.load_permissions(conn)
|
||||
user.load_groups(conn=conn)
|
||||
|
||||
return user
|
||||
|
@ -148,6 +131,38 @@ class User(LDAPObject):
|
|||
group.remove_member(self, conn=conn)
|
||||
self._groups = after
|
||||
|
||||
def load_permissions(self, conn=None):
|
||||
conn = conn or self.ldap()
|
||||
|
||||
for access_group_name, details in current_app.config["ACL"].items():
|
||||
if not details.get("FILTER") or (
|
||||
self.dn
|
||||
and conn.search_s(self.dn, ldap.SCOPE_SUBTREE, details["FILTER"])
|
||||
):
|
||||
self.permissions |= set(details.get("PERMISSIONS", []))
|
||||
self.read |= set(details.get("READ", []))
|
||||
self.write |= set(details.get("WRITE", []))
|
||||
|
||||
@property
|
||||
def can_manage_users(self):
|
||||
return "manage_users" in self.permissions
|
||||
|
||||
@property
|
||||
def can_manage_groups(self):
|
||||
return "manage_groups" in self.permissions
|
||||
|
||||
@property
|
||||
def can_manage_oidc(self):
|
||||
return "manage_oidc" in self.permissions
|
||||
|
||||
@property
|
||||
def can_delete_account(self):
|
||||
return "delete_account" in self.permissions
|
||||
|
||||
@property
|
||||
def can_impersonate_users(self):
|
||||
return "impersonate_users" in self.permissions
|
||||
|
||||
|
||||
class Group(LDAPObject):
|
||||
id = "cn"
|
||||
|
|
|
@ -189,7 +189,6 @@ class OpenIDHybridGrant(_OpenIDHybridGrant):
|
|||
|
||||
def get_audiences(self, request):
|
||||
client = request.client
|
||||
print(client)
|
||||
return [Client.get(aud).oauthClientID for aud in client.oauthAudience]
|
||||
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
<a class="ui right floated button" href="{{ url_for("account.profile_creation") }}">
|
||||
{{ _("Create a user") }}
|
||||
</a>
|
||||
<a href="{{ url_for('account.user_invitation') }}" class="ui right floated button" name="action" value="impersonate" id="impersonate">
|
||||
<a href="{{ url_for('account.user_invitation') }}" class="ui right floated button" name="action" value="invite" id="invite">
|
||||
{{ _("Invite another user") }}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if self_deletion or (user.moderator and edited_user) %}
|
||||
{% if self_deletion or (user.can_manage_users and edited_user) %}
|
||||
<div class="ui basic modal">
|
||||
<div class="ui icon header">
|
||||
<i class="user minus icon"></i>
|
||||
|
@ -56,7 +56,7 @@
|
|||
action="{{ request.url }}"
|
||||
role="form"
|
||||
enctype="multipart/form-data"
|
||||
class="ui form info{% if user.moderator and edited_user and not edited_user.has_password() %} warning{% endif %}"
|
||||
class="ui form info{% if user.can_manage_users and edited_user and not edited_user.has_password() %} warning{% endif %}"
|
||||
>
|
||||
|
||||
{#{ sui.render_field(form.csrf_token) }#}
|
||||
|
@ -95,7 +95,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.moderator %}
|
||||
{% if user.can_manage_users %}
|
||||
|
||||
{% if not edited_user %}
|
||||
|
||||
|
@ -156,7 +156,7 @@
|
|||
|
||||
<div class="ui right aligned container">
|
||||
<div class="ui stackable buttons">
|
||||
{% if user.moderator and edited_user or self_deletion %}
|
||||
{% if user.can_manage_users and edited_user or self_deletion %}
|
||||
<button type="submit" class="ui right floated basic negative button confirm" name="action" value="delete" id="delete">
|
||||
{% if user.uid != edited_user.uid %}
|
||||
{{ _("Delete the user") }}
|
||||
|
@ -166,13 +166,13 @@
|
|||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if user.admin and edited_user and user.uid != edited_user.uid %}
|
||||
{% if user.can_impersonate_users and edited_user and user.uid != edited_user.uid %}
|
||||
<a href="{{ url_for('account.impersonate', username=edited_user.uid[0]) }}" class="ui right floated basic button" name="action" value="impersonate" id="impersonate">
|
||||
{{ _("Impersonate") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if user.admin and not edited_user %}
|
||||
{% if user.can_manage_users and not edited_user %}
|
||||
<a href="{{ url_for('account.user_invitation') }}" class="ui right floated button" name="action" value="impersonate" id="impersonate">
|
||||
{{ _("Invite a user") }}
|
||||
</a>
|
||||
|
|
|
@ -43,19 +43,21 @@
|
|||
<i class="handshake icon"></i>
|
||||
{% trans %}My consents{% endtrans %}
|
||||
</a>
|
||||
{% if user.moderator %}
|
||||
{% if user.can_manage_users %}
|
||||
<a class="item {% if menuitem == "users" %}active{% endif %}"
|
||||
href="{{ url_for('account.users') }}">
|
||||
<i class="users icon"></i>
|
||||
{% trans %}Users{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.can_manage_groups %}
|
||||
<a class="item {% if menuitem == "groups" %}active{% endif %}"
|
||||
href="{{ url_for('groups.groups') }}">
|
||||
<i class="users cog icon"></i>
|
||||
{% trans %}Groups{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.admin %}
|
||||
{% if user.can_manage_oidc %}
|
||||
<div class="ui dropdown item {% if menuitem == "admin" %}active{% endif %}">
|
||||
<i class="settings icon"></i>
|
||||
Admin
|
||||
|
|
|
@ -40,10 +40,6 @@ OIDC_METADATA_FILE = "conf/openid-configuration.json"
|
|||
# wether the login exists or not.
|
||||
# HIDE_INVALID_LOGINS = false
|
||||
|
||||
# SELF_DELETION controls the ability for a user to delete his own
|
||||
# account. The default value is true.
|
||||
# SELF_DELETION = true
|
||||
|
||||
[LOGGING]
|
||||
# LEVEL can be one value among:
|
||||
# DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
|
@ -73,37 +69,64 @@ USER_FILTER = "(|(uid={login})(cn={login}))"
|
|||
# A class to use for creating new users
|
||||
USER_CLASS = "inetOrgPerson"
|
||||
|
||||
# Filter to match super admin users. Super admins can manage
|
||||
# OAuth clients, tokens and authorizations. If your LDAP server has
|
||||
# the 'memberof' overlay, you can filter against group membership.
|
||||
# ADMIN_FILTER = "uid=admin"
|
||||
ADMIN_FILTER = "memberof=cn=admins,ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# Filter to match super admin users. User admins can edit, create
|
||||
# and delete user accounts. If your LDAP server has the 'memberof'
|
||||
# overlay, you can filter against group membership.
|
||||
# USER_ADMIN_FILTER = "uid=moderator"
|
||||
USER_ADMIN_FILTER = "memberof=cn=moderators,ou=groups,dc=mydomain,dc=tld"
|
||||
|
||||
# The list of ldap fields you want to be editable by the
|
||||
# users.
|
||||
FIELDS = [
|
||||
"uid",
|
||||
"mail",
|
||||
"givenName",
|
||||
"sn",
|
||||
"userPassword",
|
||||
"telephoneNumber",
|
||||
"employeeNumber",
|
||||
# "jpegPhoto",
|
||||
"groups",
|
||||
]
|
||||
|
||||
# Where to search for groups?
|
||||
GROUP_BASE = "ou=groups"
|
||||
|
||||
# The object class to use for creating new groups
|
||||
GROUP_CLASS = "groupOfNames"
|
||||
|
||||
# The attribute to use to identify a group
|
||||
GROUP_NAME_ATTRIBUTE = "cn"
|
||||
|
||||
# A filter to check if a user belongs to a group
|
||||
GROUP_USER_FILTER = "(member={user.dn})"
|
||||
|
||||
# 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.
|
||||
#
|
||||
# A 'FILTER' parameter that is a LDAP filter used to determine if a user
|
||||
# belongs to an access control. If absent, all the users will match this
|
||||
# access control. If your LDAP server has the 'memberof' overlay, you can
|
||||
# filter against group membership.
|
||||
# Here are some examples
|
||||
# FILTER = 'uid=admin'
|
||||
# FILTER = 'memberof=cn=admins,ou=groups,dc=mydomain,dc=tld'
|
||||
#
|
||||
# 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:
|
||||
# - "manage_users" to allow other users management
|
||||
# - "manage_groups" to allow group edition and creation
|
||||
# - "manage_oidc" to allow OpenID Connect client managements
|
||||
# - "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]
|
||||
READ = ["uid", "groups"]
|
||||
WRITE = ["givenName", "sn", "userPassword", "telephoneNumber"]
|
||||
|
||||
[ACL.ADMIN]
|
||||
FILTER = "memberof=cn=admins,ou=groups,dc=mydomain,dc=tld"
|
||||
PERMISSIONS = [
|
||||
"manage_users",
|
||||
"manage_groups",
|
||||
"manage_oidc",
|
||||
"delete_account",
|
||||
"impersonate_users",
|
||||
]
|
||||
READ = ["uid"]
|
||||
WRITE = ["groups", "givenName", "sn", "userPassword"]
|
||||
|
||||
[ACL.HALF_ADMIN]
|
||||
FILTER = "memberof=cn=moderators,ou=groups,dc=mydomain,dc=tld"
|
||||
PERMISSIONS = ["manage_users", "manage_groups", "delete_account"]
|
||||
READ = ["uid"]
|
||||
WRITE = ["groups", "givenName", "sn", "userPassword"]
|
||||
|
||||
# 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
|
||||
|
|
|
@ -138,10 +138,36 @@ def configuration(slapd_server, smtpd, keypair_path):
|
|||
"USER_BASE": "ou=users",
|
||||
"USER_FILTER": "(|(uid={login})(cn={login}))",
|
||||
"USER_CLASS": "inetOrgPerson",
|
||||
"ADMIN_FILTER": "(|(uid=admin)(sn=admin))",
|
||||
"USER_ADMIN_FILTER": "(|(uid=moderator)(sn=moderator))",
|
||||
"FIELDS": [
|
||||
"uid",
|
||||
"GROUP_BASE": "ou=groups",
|
||||
"GROUP_CLASS": "groupOfNames",
|
||||
"GROUP_NAME_ATTRIBUTE": "cn",
|
||||
"GROUP_USER_FILTER": "(member={user.dn})",
|
||||
"TIMEOUT": 0.1,
|
||||
},
|
||||
"ACL": {
|
||||
"DEFAULT": {
|
||||
"READ": ["uid", "groups"],
|
||||
"PERMISSIONS": [],
|
||||
"WRITE": [
|
||||
"mail",
|
||||
"givenName",
|
||||
"sn",
|
||||
"userPassword",
|
||||
"telephoneNumber",
|
||||
"employeeNumber",
|
||||
],
|
||||
},
|
||||
"ADMIN": {
|
||||
"FILTER": "(|(uid=admin)(sn=admin))",
|
||||
"PERMISSIONS": [
|
||||
"manage_users",
|
||||
"manage_oidc",
|
||||
"delete_account",
|
||||
"impersonate_users",
|
||||
"manage_groups",
|
||||
],
|
||||
"READ": ["uid"],
|
||||
"WRITE": [
|
||||
"mail",
|
||||
"givenName",
|
||||
"sn",
|
||||
|
@ -150,11 +176,21 @@ def configuration(slapd_server, smtpd, keypair_path):
|
|||
"employeeNumber",
|
||||
"groups",
|
||||
],
|
||||
"GROUP_BASE": "ou=groups",
|
||||
"GROUP_CLASS": "groupOfNames",
|
||||
"GROUP_NAME_ATTRIBUTE": "cn",
|
||||
"GROUP_USER_FILTER": "(member={user.dn})",
|
||||
"TIMEOUT": 0.1,
|
||||
},
|
||||
"MODERATOR": {
|
||||
"FILTER": "(|(uid=moderator)(sn=moderator))",
|
||||
"PERMISSIONS": ["manage_users", "manage_groups", "delete_account"],
|
||||
"READ": ["uid"],
|
||||
"WRITE": [
|
||||
"mail",
|
||||
"givenName",
|
||||
"sn",
|
||||
"userPassword",
|
||||
"telephoneNumber",
|
||||
"employeeNumber",
|
||||
"groups",
|
||||
],
|
||||
},
|
||||
},
|
||||
"JWT": {
|
||||
"PUBLIC_KEY": public_key_path,
|
||||
|
|
6
tests/fixtures/themes/test/base.html
vendored
6
tests/fixtures/themes/test/base.html
vendored
|
@ -43,19 +43,21 @@
|
|||
<i class="handshake icon"></i>
|
||||
{% trans %}My consents{% endtrans %}
|
||||
</a>
|
||||
{% if user.moderator %}
|
||||
{% if user.can_manage_users %}
|
||||
<a class="item {% if menuitem == "users" %}active{% endif %}"
|
||||
href="{{ url_for('account.users') }}">
|
||||
<i class="users icon"></i>
|
||||
{% trans %}Users{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.can_manage_groups %}
|
||||
<a class="item {% if menuitem == "groups" %}active{% endif %}"
|
||||
href="{{ url_for('groups.groups') }}">
|
||||
<i class="users cog icon"></i>
|
||||
{% trans %}Groups{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.admin %}
|
||||
{% if user.can_manage_oidc %}
|
||||
<div class="ui dropdown item {% if menuitem == "admin" %}active{% endif %}">
|
||||
<i class="settings icon"></i>
|
||||
Admin
|
||||
|
|
|
@ -162,10 +162,6 @@ def test_admin_self_deletion(testclient, slapd_connection):
|
|||
.follow(status=200)
|
||||
)
|
||||
|
||||
testclient.app.config["SELF_DELETION"] = True
|
||||
|
||||
testclient.app.config["SELF_DELETION"] = False
|
||||
|
||||
with testclient.app.app_context():
|
||||
assert User.get("temp", conn=slapd_connection) is None
|
||||
|
||||
|
@ -187,11 +183,11 @@ def test_user_self_deletion(testclient, slapd_connection):
|
|||
with testclient.session_transaction() as sess:
|
||||
sess["user_dn"] = [user.dn]
|
||||
|
||||
testclient.app.config["SELF_DELETION"] = False
|
||||
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
|
||||
res = testclient.get("/profile/temp")
|
||||
assert "Delete my account" not in res
|
||||
|
||||
testclient.app.config["SELF_DELETION"] = True
|
||||
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = ["delete_account"]
|
||||
res = testclient.get("/profile/temp")
|
||||
assert "Delete my account" in res
|
||||
res = (
|
||||
|
@ -206,4 +202,4 @@ def test_user_self_deletion(testclient, slapd_connection):
|
|||
with testclient.session_transaction() as sess:
|
||||
assert not sess.get("user_dn")
|
||||
|
||||
testclient.app.config["SELF_DELETION"] = False
|
||||
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from canaille.models import User
|
||||
|
||||
|
||||
def test_profile(
|
||||
def test_edition(
|
||||
testclient, slapd_server, slapd_connection, logged_user, admin, foo_group, bar_group
|
||||
):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
|
@ -49,6 +49,81 @@ def test_profile(
|
|||
assert logged_user.check_password("correct horse battery staple")
|
||||
|
||||
|
||||
def test_field_permissions_none(
|
||||
testclient, slapd_server, slapd_connection, logged_user
|
||||
):
|
||||
testclient.get("/profile/user", status=200)
|
||||
with testclient.app.app_context():
|
||||
logged_user.telephoneNumber = ["555-666-777"]
|
||||
logged_user.save(conn=slapd_connection)
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"] = {"READ": ["uid"], "WRITE": []}
|
||||
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert "telephoneNumber" not in res.form.fields
|
||||
|
||||
testclient.post(
|
||||
"/profile/user", {"action": "edit", "telephoneNumber": "000-000-000"}
|
||||
)
|
||||
with testclient.app.app_context():
|
||||
user = User.get(dn=logged_user.dn, conn=slapd_connection)
|
||||
assert user.telephoneNumber == ["555-666-777"]
|
||||
|
||||
|
||||
def test_field_permissions_read(
|
||||
testclient, slapd_server, slapd_connection, logged_user
|
||||
):
|
||||
testclient.get("/profile/user", status=200)
|
||||
with testclient.app.app_context():
|
||||
logged_user.telephoneNumber = ["555-666-777"]
|
||||
logged_user.save(conn=slapd_connection)
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"] = {
|
||||
"READ": ["uid", "telephoneNumber"],
|
||||
"WRITE": [],
|
||||
}
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert "telephoneNumber" in res.form.fields
|
||||
|
||||
testclient.post(
|
||||
"/profile/user", {"action": "edit", "telephoneNumber": "000-000-000"}
|
||||
)
|
||||
with testclient.app.app_context():
|
||||
user = User.get(dn=logged_user.dn, conn=slapd_connection)
|
||||
assert user.telephoneNumber == ["555-666-777"]
|
||||
|
||||
|
||||
def test_field_permissions_write(
|
||||
testclient, slapd_server, slapd_connection, logged_user
|
||||
):
|
||||
testclient.get("/profile/user", status=200)
|
||||
with testclient.app.app_context():
|
||||
logged_user.telephoneNumber = ["555-666-777"]
|
||||
logged_user.save(conn=slapd_connection)
|
||||
|
||||
testclient.app.config["ACL"]["DEFAULT"] = {
|
||||
"READ": ["uid"],
|
||||
"WRITE": ["telephoneNumber"],
|
||||
}
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
assert "telephoneNumber" in res.form.fields
|
||||
|
||||
testclient.post(
|
||||
"/profile/user", {"action": "edit", "telephoneNumber": "000-000-000"}
|
||||
)
|
||||
with testclient.app.app_context():
|
||||
user = User.get(dn=logged_user.dn, conn=slapd_connection)
|
||||
assert user.telephoneNumber == ["000-000-000"]
|
||||
|
||||
|
||||
def test_simple_user_cannot_edit_other(testclient, logged_user):
|
||||
testclient.get("/profile/user", status=200)
|
||||
testclient.get("/profile/admin", status=403)
|
||||
testclient.post("/profile/admin", {"action": "edit"}, status=403)
|
||||
testclient.post("/profile/admin", {"action": "delete"}, status=403)
|
||||
testclient.get("/users", status=403)
|
||||
|
||||
|
||||
def test_bad_email(testclient, slapd_connection, logged_user):
|
||||
res = testclient.get("/profile/user", status=200)
|
||||
|
||||
|
@ -114,14 +189,6 @@ def test_password_change_fail(testclient, slapd_connection, logged_user):
|
|||
assert logged_user.check_password("correct horse battery staple")
|
||||
|
||||
|
||||
def test_simple_user_cannot_edit_other(testclient, logged_user):
|
||||
testclient.get("/profile/user", status=200)
|
||||
testclient.get("/profile/admin", status=403)
|
||||
testclient.post("/profile/admin", {"action": "edit"}, status=403)
|
||||
testclient.post("/profile/admin", {"action": "delete"}, status=403)
|
||||
testclient.get("/users", status=403)
|
||||
|
||||
|
||||
def test_admin_bad_request(testclient, logged_moderator):
|
||||
testclient.post("/profile/admin", {"action": "foobar"}, status=400)
|
||||
testclient.get("/profile/foobar", status=404)
|
||||
|
|
Loading…
Reference in a new issue