Permissions overhaul

This commit is contained in:
Éloi Rivard 2021-12-02 18:23:14 +01:00
parent 6dc401e170
commit d2611abadb
19 changed files with 348 additions and 196 deletions

View file

@ -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()

View file

@ -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(

View file

@ -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)

View file

@ -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(

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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):

View file

@ -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)

View file

@ -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"

View file

@ -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]

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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"] = []

View file

@ -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)