Allow finer control in rights plugin

New permissions:

R: read collections without tag
r: read collections with tag and included objects
W: write and delete collections without tag
w: write and delete collection with tag and included objects
This commit is contained in:
Unrud 2018-08-21 18:43:45 +02:00
parent 72501c6e23
commit 0a492a00b1
6 changed files with 124 additions and 116 deletions

View File

@ -144,32 +144,33 @@ class Application:
def collect_allowed_items(self, items, user): def collect_allowed_items(self, items, user):
"""Get items from request that user is allowed to access.""" """Get items from request that user is allowed to access."""
read_allowed_items = []
write_allowed_items = []
for item in items: for item in items:
if isinstance(item, storage.BaseCollection): if isinstance(item, storage.BaseCollection):
path = storage.sanitize_path("/%s/" % item.path) path = storage.sanitize_path("/%s/" % item.path)
can_read = self.Rights.authorized(user, path, "r") if item.get_meta("tag"):
can_write = self.Rights.authorized(user, path, "w") permissions = self.Rights.authorized(user, path, "rw")
target = "collection with tag %r" % item.path
else:
permissions = self.Rights.authorized(user, path, "RW")
target = "collection %r" % item.path target = "collection %r" % item.path
else: else:
path = storage.sanitize_path("/%s/%s" % (item.collection.path, path = storage.sanitize_path("/%s/" % item.collection.path)
item.href)) permissions = self.Rights.authorized(user, path, "rw")
can_read = self.Rights.authorized_item(user, path, "r")
can_write = self.Rights.authorized_item(user, path, "w")
target = "item %r from %r" % (item.href, item.collection.path) target = "item %r from %r" % (item.href, item.collection.path)
text_status = [] if rights.intersect_permissions(permissions, "Ww"):
if can_read: permission = "w"
text_status.append("read") status = "write"
read_allowed_items.append(item) elif rights.intersect_permissions(permissions, "Rr"):
if can_write: permission = "r"
text_status.append("write") status = "read"
write_allowed_items.append(item) else:
permission = ""
status = "NO"
logger.debug( logger.debug(
"%s has %s access to %s", "%s has %s access to %s",
repr(user) if user else "anonymous user", repr(user) if user else "anonymous user", status, target)
" and ".join(text_status) if text_status else "NO", target) if permission:
return read_allowed_items, write_allowed_items yield item, permission
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
with log.register_stream(environ["wsgi.errors"]): with log.register_stream(environ["wsgi.errors"]):
@ -315,7 +316,7 @@ class Application:
# Create principal collection # Create principal collection
if user: if user:
principal_path = "/%s/" % user principal_path = "/%s/" % user
if self.Rights.authorized(user, principal_path, "w"): if self.Rights.authorized(user, principal_path, "W"):
with self.Collection.acquire_lock("r", user): with self.Collection.acquire_lock("r", user):
principal = next( principal = next(
self.Collection.discover(principal_path, depth="1"), self.Collection.discover(principal_path, depth="1"),
@ -365,19 +366,28 @@ class Application:
return response(status, headers, answer) return response(status, headers, answer)
def _access(self, user, path, permission, item=None): def _access(self, user, path, permission, item=None):
"""Check if ``user`` can access ``path`` or the parent collection. if permission not in "rw":
raise ValueError("Invalid permission argument: %r" % permission)
``permission`` must either be "r" or "w". if not item:
permissions = permission + permission.upper()
If ``item`` is given, only access to that class of item is checked. parent_permissions = permission
elif isinstance(item, storage.BaseCollection):
""" if item.get_meta("tag"):
allowed = False permissions = permission
if not item or isinstance(item, storage.BaseCollection): else:
allowed |= self.Rights.authorized(user, path, permission) permissions = permission.upper()
if not item or not isinstance(item, storage.BaseCollection): parent_permissions = ""
allowed |= self.Rights.authorized_item(user, path, permission) else:
return allowed permissions = ""
parent_permissions = permission
if permissions and self.Rights.authorized(user, path, permissions):
return True
if parent_permissions:
parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
if self.Rights.authorized(user, parent_path, parent_permissions):
return True
return False
def _read_raw_content(self, environ): def _read_raw_content(self, environ):
content_length = int(environ.get("CONTENT_LENGTH") or 0) content_length = int(environ.get("CONTENT_LENGTH") or 0)
@ -459,10 +469,10 @@ class Application:
return NOT_ALLOWED return NOT_ALLOWED
with self.Collection.acquire_lock("w", user): with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not item: if not item:
return NOT_FOUND return NOT_FOUND
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if_match = environ.get("HTTP_IF_MATCH", "*") if_match = environ.get("HTTP_IF_MATCH", "*")
if if_match not in ("*", item.etag): if if_match not in ("*", item.etag):
# ETag precondition not verified, do not delete item # ETag precondition not verified, do not delete item
@ -493,10 +503,10 @@ class Application:
return NOT_ALLOWED return NOT_ALLOWED
with self.Collection.acquire_lock("r", user): with self.Collection.acquire_lock("r", user):
item = next(self.Collection.discover(path), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item: if not item:
return NOT_FOUND return NOT_FOUND
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if isinstance(item, storage.BaseCollection): if isinstance(item, storage.BaseCollection):
tag = item.get_meta("tag") tag = item.get_meta("tag")
if not tag: if not tag:
@ -563,7 +573,8 @@ class Application:
def do_MKCOL(self, environ, base_prefix, path, user): def do_MKCOL(self, environ, base_prefix, path, user):
"""Manage MKCOL request.""" """Manage MKCOL request."""
if not self.Rights.authorized(user, path, "w"): permissions = self.Rights.authorized(user, path, "Ww")
if not permissions:
return NOT_ALLOWED return NOT_ALLOWED
try: try:
xml_content = self._read_xml_content(environ) xml_content = self._read_xml_content(environ)
@ -587,6 +598,9 @@ class Application:
parent_item.get_meta("tag")): parent_item.get_meta("tag")):
return FORBIDDEN return FORBIDDEN
props = xmlutils.props_from_request(xml_content) props = xmlutils.props_from_request(xml_content)
if (props.get("tag") and "w" not in permissions or
not props.get("tag") and "W" not in permissions):
return NOT_ALLOWED
try: try:
storage.check_and_sanitize_props(props) storage.check_and_sanitize_props(props)
self.Collection.create_collection(path, props=props) self.Collection.create_collection(path, props=props)
@ -617,12 +631,11 @@ class Application:
with self.Collection.acquire_lock("w", user): with self.Collection.acquire_lock("w", user):
item = next(self.Collection.discover(path), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not self._access(user, to_path, "w", item):
return NOT_ALLOWED
if not item: if not item:
return NOT_FOUND return NOT_FOUND
if (not self._access(user, path, "w", item) or
not self._access(user, to_path, "w", item)):
return NOT_ALLOWED
if isinstance(item, storage.BaseCollection): if isinstance(item, storage.BaseCollection):
# TODO: support moving collections # TODO: support moving collections
return METHOD_NOT_ALLOWED return METHOD_NOT_ALLOWED
@ -682,24 +695,24 @@ class Application:
path, environ.get("HTTP_DEPTH", "0")) path, environ.get("HTTP_DEPTH", "0"))
# take root item for rights checking # take root item for rights checking
item = next(items, None) item = next(items, None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item: if not item:
return NOT_FOUND return NOT_FOUND
if not self._access(user, path, "r", item):
return NOT_ALLOWED
# put item back # put item back
items = itertools.chain([item], items) items = itertools.chain([item], items)
read_items, write_items = self.collect_allowed_items(items, user) allowed_items = self.collect_allowed_items(items, user)
headers = {"DAV": DAV_HEADERS, headers = {"DAV": DAV_HEADERS,
"Content-Type": "text/xml; charset=%s" % self.encoding} "Content-Type": "text/xml; charset=%s" % self.encoding}
status, xml_answer = xmlutils.propfind( status, xml_answer = xmlutils.propfind(
base_prefix, path, xml_content, read_items, write_items, user) base_prefix, path, xml_content, allowed_items, user)
if status == client.FORBIDDEN: if status == client.FORBIDDEN:
return NOT_ALLOWED return NOT_ALLOWED
return status, headers, self._write_xml_content(xml_answer) return status, headers, self._write_xml_content(xml_answer)
def do_PROPPATCH(self, environ, base_prefix, path, user): def do_PROPPATCH(self, environ, base_prefix, path, user):
"""Manage PROPPATCH request.""" """Manage PROPPATCH request."""
if not self.Rights.authorized(user, path, "w"): if not self._access(user, path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
try: try:
xml_content = self._read_xml_content(environ) xml_content = self._read_xml_content(environ)
@ -714,6 +727,8 @@ class Application:
item = next(self.Collection.discover(path), None) item = next(self.Collection.discover(path), None)
if not item: if not item:
return NOT_FOUND return NOT_FOUND
if not self._access(user, path, "w", item):
return NOT_ALLOWED
if not isinstance(item, storage.BaseCollection): if not isinstance(item, storage.BaseCollection):
return FORBIDDEN return FORBIDDEN
headers = {"DAV": DAV_HEADERS, headers = {"DAV": DAV_HEADERS,
@ -754,7 +769,7 @@ class Application:
if write_whole_collection: if write_whole_collection:
if not self.Rights.authorized(user, path, "w"): if not self.Rights.authorized(user, path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
elif not self.Rights.authorized_item(user, path, "w"): elif not self.Rights.authorized(user, parent_path, "w"):
return NOT_ALLOWED return NOT_ALLOWED
etag = environ.get("HTTP_IF_MATCH", "") etag = environ.get("HTTP_IF_MATCH", "")
@ -850,10 +865,10 @@ class Application:
return REQUEST_TIMEOUT return REQUEST_TIMEOUT
with self.Collection.acquire_lock("r", user): with self.Collection.acquire_lock("r", user):
item = next(self.Collection.discover(path), None) item = next(self.Collection.discover(path), None)
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if not item: if not item:
return NOT_FOUND return NOT_FOUND
if not self._access(user, path, "r", item):
return NOT_ALLOWED
if isinstance(item, storage.BaseCollection): if isinstance(item, storage.BaseCollection):
collection = item collection = item
else: else:

View File

@ -39,7 +39,6 @@ Leading or ending slashes are trimmed from collection's path.
import configparser import configparser
import os.path import os.path
import posixpath
import re import re
from importlib import import_module from importlib import import_module
@ -75,59 +74,61 @@ def load(configuration):
return rights_class(configuration) return rights_class(configuration)
def intersect_permissions(a, b="RrWw"):
return "".join(set(a).intersection(set(b)))
class BaseRights: class BaseRights:
def __init__(self, configuration): def __init__(self, configuration):
self.configuration = configuration self.configuration = configuration
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
"""Check if the user is allowed to read or write the collection. """Check if the user is allowed to read or write the collection.
If ``user`` is empty, check for anonymous rights. If ``user`` is empty, check for anonymous rights.
``path`` is sanitized. ``path`` is sanitized.
``permission`` is "r" or "w". ``permissions`` can include "R", "r", "W", "w"
Returns granted rights.
""" """
raise NotImplementedError raise NotImplementedError
def authorized_item(self, user, path, permission):
"""Check if the user is allowed to read or write the item."""
path = storage.sanitize_path(path)
parent_path = storage.sanitize_path(
"/%s/" % posixpath.dirname(path.strip("/")))
return self.authorized(user, parent_path, permission)
class NoneRights(BaseRights): class NoneRights(BaseRights):
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
return True return intersect_permissions(permissions)
class AuthenticatedRights(BaseRights): class AuthenticatedRights(BaseRights):
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
return bool(user) if not user:
return ""
return intersect_permissions(permissions)
class OwnerWriteRights(BaseRights): class OwnerWriteRights(BaseRights):
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
if not user:
return ""
sane_path = storage.sanitize_path(path).strip("/") sane_path = storage.sanitize_path(path).strip("/")
return bool(user) and (permission == "r" or if user != sane_path.split("/", maxsplit=1)[0]:
user == sane_path.split("/", maxsplit=1)[0]) return intersect_permissions(permissions, "Rr")
return intersect_permissions(permissions)
class OwnerOnlyRights(BaseRights): class OwnerOnlyRights(BaseRights):
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
if not user:
return ""
sane_path = storage.sanitize_path(path).strip("/") sane_path = storage.sanitize_path(path).strip("/")
return bool(user) and ( if not sane_path:
permission == "r" and not sane_path or return intersect_permissions(permissions, "R")
user == sane_path.split("/", maxsplit=1)[0]) if user != sane_path.split("/", maxsplit=1)[0]:
return ""
def authorized_item(self, user, path, permission): return intersect_permissions(permissions)
sane_path = storage.sanitize_path(path).strip("/")
if "/" not in sane_path:
return False
return super().authorized_item(user, path, permission)
class Rights(BaseRights): class Rights(BaseRights):
@ -135,41 +136,41 @@ class Rights(BaseRights):
super().__init__(configuration) super().__init__(configuration)
self.filename = os.path.expanduser(configuration.get("rights", "file")) self.filename = os.path.expanduser(configuration.get("rights", "file"))
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
user = user or "" user = user or ""
sane_path = storage.sanitize_path(path).strip("/") sane_path = storage.sanitize_path(path).strip("/")
# Prevent "regex injection" # Prevent "regex injection"
user_escaped = re.escape(user) user_escaped = re.escape(user)
sane_path_escaped = re.escape(sane_path) sane_path_escaped = re.escape(sane_path)
regex = configparser.ConfigParser( rights_config = configparser.ConfigParser(
{"login": user_escaped, "path": sane_path_escaped}) {"login": user_escaped, "path": sane_path_escaped})
try: try:
if not regex.read(self.filename): if not rights_config.read(self.filename):
raise RuntimeError("No such file: %r" % raise RuntimeError("No such file: %r" %
self.filename) self.filename)
except Exception as e: except Exception as e:
raise RuntimeError("Failed to load rights file %r: %s" % raise RuntimeError("Failed to load rights file %r: %s" %
(self.filename, e)) from e (self.filename, e)) from e
for section in regex.sections(): for section in rights_config.sections():
try: try:
re_user_pattern = regex.get(section, "user") user_pattern = rights_config.get(section, "user")
re_collection_pattern = regex.get(section, "collection") collection_pattern = rights_config.get(section, "collection")
# Emulate fullmatch user_match = re.fullmatch(user_pattern, user)
user_match = re.match(r"(?:%s)\Z" % re_user_pattern, user) collection_match = user_match and re.fullmatch(
collection_match = user_match and re.match( collection_pattern.format(
r"(?:%s)\Z" % re_collection_pattern.format(
*map(re.escape, user_match.groups())), sane_path) *map(re.escape, user_match.groups())), sane_path)
except Exception as e: except Exception as e:
raise RuntimeError("Error in section %r of rights file %r: " raise RuntimeError("Error in section %r of rights file %r: "
"%s" % (section, self.filename, e)) from e "%s" % (section, self.filename, e)) from e
if user_match and collection_match: if user_match and collection_match:
logger.debug("Rule %r:%r matches %r:%r from section %r", logger.debug("Rule %r:%r matches %r:%r from section %r",
user, sane_path, re_user_pattern, user, sane_path, user_pattern,
re_collection_pattern, section) collection_pattern, section)
return permission in regex.get(section, "permission") return intersect_permissions(
permissions, rights_config.get(section, "permissions"))
else: else:
logger.debug("Rule %r:%r doesn't match %r:%r from section %r", logger.debug("Rule %r:%r doesn't match %r:%r from section %r",
user, sane_path, re_user_pattern, user, sane_path, user_pattern,
re_collection_pattern, section) collection_pattern, section)
logger.info("Rights: %r:%r doesn't match any section", user, sane_path) logger.info("Rights: %r:%r doesn't match any section", user, sane_path)
return False return ""

View File

@ -23,5 +23,7 @@ from radicale import rights
class Rights(rights.BaseRights): class Rights(rights.BaseRights):
def authorized(self, user, path, permission): def authorized(self, user, path, permissions):
return path.strip("/") in ("tmp", "other") if path.strip("/") not in ("tmp", "other"):
return ""
return rights.intersect_permissions(permissions)

View File

@ -118,11 +118,11 @@ class TestBaseAuthRequests(BaseTest):
[owner] [owner]
user: .+ user: .+
collection: %(login)s(/.*)? collection: %(login)s(/.*)?
permission: rw permissions: RrWw
[custom] [custom]
user: .* user: .*
collection: custom(/.*)? collection: custom(/.*)?
permission: r""") permissions: Rr""")
self.configuration["rights"]["file"] = rights_file_path self.configuration["rights"]["file"] = rights_file_path
self._test_rights("from_file", "", "/other", "r", 401) self._test_rights("from_file", "", "/other", "r", 401)
self._test_rights("from_file", "tmp", "/other", "r", 403) self._test_rights("from_file", "tmp", "/other", "r", 403)

View File

@ -771,8 +771,7 @@ def delete(base_prefix, path, collection, href=None):
return multistatus return multistatus
def propfind(base_prefix, path, xml_request, read_collections, def propfind(base_prefix, path, xml_request, allowed_items, user):
write_collections, user):
"""Read and answer PROPFIND requests. """Read and answer PROPFIND requests.
Read rfc4918-9.1 for info. Read rfc4918-9.1 for info.
@ -805,19 +804,10 @@ def propfind(base_prefix, path, xml_request, read_collections,
# Writing answer # Writing answer
multistatus = ET.Element(_tag("D", "multistatus")) multistatus = ET.Element(_tag("D", "multistatus"))
collections = [] for item, permission in allowed_items:
for collection in write_collections: write = permission == "w"
collections.append(collection)
response = _propfind_response( response = _propfind_response(
base_prefix, path, collection, props, user, write=True, base_prefix, path, item, props, user, write=write,
allprop=allprop, propname=propname)
if response:
multistatus.append(response)
for collection in read_collections:
if collection in collections:
continue
response = _propfind_response(
base_prefix, path, collection, props, user, write=False,
allprop=allprop, propname=propname) allprop=allprop, propname=propname)
if response: if response:
multistatus.append(response) multistatus.append(response)

10
rights
View File

@ -19,30 +19,30 @@
[admin] [admin]
user: admin.* user: admin.*
collection: .* collection: .*
permission: r permissions: Rr
# This means all users may read and write any collection starting with public. # This means all users may read and write any collection starting with public.
# We do so by just not testing against the user string. # We do so by just not testing against the user string.
[public] [public]
user: .* user: .*
collection: public(/.+)? collection: public(/.+)?
permission: rw permissions: RrWw
# A little more complex: give read access to users from a domain for all # A little more complex: give read access to users from a domain for all
# collections of all the users (ie. user@domain.tld can read domain/*). # collections of all the users (ie. user@domain.tld can read domain/*).
[domain-wide-access] [domain-wide-access]
user: .+@(.+)\..+ user: .+@(.+)\..+
collection: {0}/.+ collection: {0}/.+
permission: r permissions: Rr
# Allow authenticated user to read all collections # Allow authenticated user to read all collections
[allow-everyone-read] [allow-everyone-read]
user: .+ user: .+
collection: .* collection: .*
permission: r permissions: Rr
# Give write access to owners # Give write access to owners
[owner-write] [owner-write]
user: .+ user: .+
collection: %(login)s/.* collection: %(login)s/.*
permission: w permissions: Ww