# $SnapHashLicense:
#
# SnapLogic - Open source data services
#
# Copyright (C) 2008-2009, SnapLogic, Inc. All rights reserved.
#
# See http://www.snaplogic.org for more information about
# the SnapLogic project.
#
# This program is free software, distributed under the terms of
# the GNU General Public License Version 2. See the LEGAL file
# at the top of the source tree.
#
# "SnapLogic" is a trademark of SnapLogic, Inc.
#
#
# $
# $Id $
"""
This module contains authentication and authorization
related classes and functions.
"""
from __future__ import with_statement
import os
import cgi
import time
import random
import sets
import string
import shutil
import tempfile
from urllib import unquote,quote
from threading import Lock
log = None
elog = None
rlog = None
from snaplogic import server
from snaplogic.common.version_info import is_pe,server_edition
from snaplogic.common import snap_log,file_lock
from snaplogic.common import snap_exceptions
from paste.auth.basic import AuthBasicHandler
from paste.httpheaders import *
from snaplogic.server.auth.xml_access_parser import XmlAccessParser
from snaplogic.common import uri_prefix
from snaplogic.server.http_request import HttpRequest
from snaplogic.server import RhResponse,product_prefix
from snaplogic.common import headers
from snaplogic.common import snap_crypt
from snaplogic.snapi_base import keys
from snaplogic.snapi_base.exceptions import ERROR_GEN_ID_CONFLICT
from snaplogic import server
from snaplogic.common.snap_crypt import md5crypt
# Core (fka Community) edition allows only one additional user (besides a number
# of 'system users', which are necessary for the proper operation of
# the SnapLogic system and the demonstration of its capabilities.
# These two variables here are only used in the CE.
CE_USER_LIMIT = 1
CE_SYSTEM_USERS = [ 'admin', 'snapdemo', 'main', 'cc1' ]
ADMIN_USER = "admin"
"""The name of the privileged admin user. No surprise, it is ... 'admin'!"""
__auth_lock = Lock()
"""Locks the entire set of auth structures, so that multiple requests cannot mess with it at the same time."""
# We keep track of various tables and auth related pieces of information
# in these module-level variables.
__user_lookup = {}
# A dictionary for the non-collapsed, non-optimized, easily readable ACL rules.
# This is used to read ACLs when the user requests access to them, and also to
# buffer any changes to them. If they are changed then __prepare_fast_permission_lookup()
# needs to be called with __acl_rules as argument.
__acl_rules = {}
class __user(object):
"""Information about a user stored in an internal lookup table."""
password = None
groups = None
description = None
email = None
genid = None
def __init__(self, password, description="", email=""):
self.groups = []
self.password = password
self.description = description
self.email = email
self.genid = _make_start_genid()
def add_group(self, gname):
if gname not in self.groups:
self.groups.append(gname)
def del_group(self, gname):
try:
i = self.groups.index(gname)
self.groups.pop(i)
except:
pass
def __str__(self):
return "Password: %s, Groups: %s, Description: %s, Email: %s, GenID: %s" % (self.password, self.groups, self.description, self.email, self.genid)
class __group(object):
"""Information about a user group stored in an internal lookup table."""
description = None
users = None
genid = None
def __init__(self, users, description=""):
self.users = users
self.description = description
self.genid = _make_start_genid()
def add_user(self, uname):
if uname not in self.users:
self.users.append(uname)
def del_user(self, uname):
try:
i = self.users.index(uname)
self.users.pop(i)
except:
# No error if the user didn't exist
pass
def replace_users(self, new_users):
self.users = new_users
def __str__(self):
return "Description: %s, Users: %s, GenID: %s" % (self.description, self.users, self.genid)
"""Lookup table for users and groups."""
__groups = {}
"""Lookup table to find groups and their users."""
__path_rules = None
"""Given a path, we can find the permission loookup dictionaries for users and groups."""
__prefix_list = None
"""Longest-prefix matching list (sorted by length, to make finding the right path_rule quicker."""
__password_cache = {}
"""Provides a password cache for authenticated users, so that we don't have to perform the
expensive password encryption for every request."""
__password_file = None
"""The name of the password file, containing users and their passwords."""
__auth_acl_file = None
"""The name of the Snap Access file, containing group and ACL definitions."""
__need_access_uri = [ uri_prefix.ROOT, uri_prefix.INFO ]
"""This is a list of URIs to which anyone who wants to connect to the server needs to
have access. If a user doesn't have access, the module will produce a warning."""
__feed_prefix = None
"""The URI prefix that is used to identify special feed URI. Specified in config file, cached locally."""
__feed_prefix_len = 0
# Permission sets are stored as bit masks. Here are the bit values for
# the three different permissions that we know about in our system.
PERM_READ = 1
PERM_WRITE = 2
PERM_EXEC = 4
# Some convenience functions to check if a permission bit set
# contains a specific permission
can_read = lambda p : p & PERM_READ
can_write = lambda p : p & PERM_WRITE
can_exec = lambda p : p & PERM_EXEC
# Since permissions are described in textual form in the auth config
# file(s), we have a translation table here, which allows us to convert
# the textual description to the proper bit-mask value.
perm_lookup = { 'read' : PERM_READ, 'write' : PERM_WRITE, 'execute' : PERM_EXEC }
# We use a tuple for something later on, and the positional arguments
# in the tuple of course don't have names. Well, for that purpose here
# we define their indices with names that are more sensible.
USER_PATH_RULE = 0
GROUP_PATH_RULE = 1
GROUP_NAME_SET = 2
def _make_start_genid():
"""
Create a starting Gen-ID for an auth object.
This is a random number, so that a client will notice if the server
has restarted between two operations. If the number would be static
then conceivably the could just happen to have the correct Gen-ID,
successfully update and wipe out changes that had been applied by
other clients in the meantime.
We can reduce this risk by using a random Gen-ID when the object is
created for the first time.
@return: String representation of a random number.
@rtype: string
"""
return str(random.randint(100000000,999999999))
def _increase_genid(genid):
"""
Return a changed genid (after an update of any kind).
We use a separate function for this, since the genid might be of
some more advanced format in the furture. Currently, it is a string
representing a number. The proper string operations don't have to
be scattered across our code if we provide a small helper function
for this.
@param genid: The genid of an object.
@type genid: string
@return: Increased genid.
@rtype: string
"""
return str(int(genid)+1)
def _store_user_db():
"""
Write a new password file based on the user/password dictionary.
All previous contents of the file will be overwritten.
"""
try:
(tmp_fd, tmp_file) = tempfile.mkstemp()
with os.fdopen(tmp_fd, "w+") as fhandle:
file_lock.lock(fhandle, file_lock.LOCK_EX)
fhandle.write("\n# This file was autogenerated. Do not edit by hand!\n")
fhandle.write("# Last modification: %s\n\n" % time.strftime("%d %b %Y, %H:%M:%S", time.localtime()))
# Sort the list by username
users = __user_lookup.keys()
users.sort()
for username in users:
ue = __user_lookup[username]
fhandle.write("%s:%s:%s:%s\n" % (username, ue.password, ue.email, ue.description))
fhandle.flush()
fhandle.close()
# Now that the file is correctly written, we can move it in place
# of the original.
shutil.move(tmp_file, __password_file)
except IOError, e:
elog("Cannot modify password file. Error: %s" % e)
return RhResponse(500, "Internal server error")
return None
def __make_rule_strings(rules):
"""
Convert a list of rule dictionaries into textual information.
"""
rule_strings = []
for r in rules:
verb = r['verb'].upper()
if verb == "ALLOW":
rule_strings.append("%s %s %s PERMISSION %s" % ( verb, r['principal_type'].upper(), ' '.join(r['principal']), ' '.join(r['permissions'])))
else:
rule_strings.append("%s %s %s" % ( verb, r['principal_type'].upper(), ' '.join(r['principal'])))
return rule_strings
def _store_groups_and_acls():
"""
Write the snap-access file, containing group and ACL definitions.
All previous contents of the file will be overwritten.
"""
try:
(tmp_fd, tmp_file) = tempfile.mkstemp()
with os.fdopen(tmp_fd, "w+") as fhandle:
file_lock.lock(fhandle, file_lock.LOCK_EX)
# Write comments and other boiler plate
fhandle.write("\n# This file was autogenerated.\n# If the server is not running it may be edited by hand!\n")
fhandle.write("# Last modification: %s\n\n" % time.strftime("%d %b %Y, %H:%M:%S", time.localtime()))
fhandle.write("""
<AccessConfig>
# Groups only need to be defined if we refer to them specifically
# in the location rules. There are also implied groups, such as
# 'public' (everyone) or 'known' (those who have been authenticated).
# Access rules just with the user names or those two groups can be
# constructed, in which case no additional groups need to be defined.
#
<Groups>
# groupname description
""")
# Dump the group definitions
for gname in __groups:
esc_name = cgi.escape(gname)
fhandle.write(" %s%s%s\n" % (esc_name, " " * (20-len(esc_name)), cgi.escape(__groups[gname].description)))
fhandle.write("""
</Groups>
# If groups are defined, then users can be assigned to them.
# This assignment is not mandatory, but of course, the definition
# of groups would be quite pointless without it.
#
<UserGroups>
# username groupname1 groupname2 ...
""")
# Dump the user to group assignments
for uname in __user_lookup:
u = __user_lookup[uname]
if u.groups:
esc_name = cgi.escape(uname)
fhandle.write(" %s%s%s\n" % (esc_name, " " * (20-len(esc_name)), ' '.join(u.groups)))
fhandle.write("""
</UserGroups>
# There are two implicit groups: 'public' and 'known'.
# Everyone is always member of 'public', even if not authenticated.
# Once authenticated, you also become a member of 'known'.
#
# Matching locations are evaluated with the longest prefix first.
# So, you can specify:
#
# <Location name="/foo">
# deny user joe
# </Location>
# <Location name="/foo/bar">
# allow user joe permission use
# </Location>
#
# This gives 'joe' use-access to '/foo/bar' (and everything below),
# but denies all access to '/foo' (and everything below, except 'bar').
#
# If a specific match for a user is found, the traversing of the path
# is stopped and the permissions are taken from that match. Permissions
# through group matches (a user can be a member of multiple groups)
# however are accumulative.
<ACLs>
""")
# Dump the ACLs
for name in __acl_rules:
(rules, description, genid) = __acl_rules[name]
if description:
desc_str = " description=\"%s\"" % cgi.escape(description)
else:
desc_str = ""
fhandle.write(" <Location name=\"%s\"%s>\n" % (name, desc_str))
rule_strings = __make_rule_strings(rules)
s = ""
for rs in rule_strings:
s += " %s\n" % rs
fhandle.write(s)
fhandle.write(" </Location>\n")
fhandle.write("""
</ACLs>
</AccessConfig>
""")
fhandle.flush()
fhandle.close()
# Now that the file is correctly written, we can move it in place
# of the original.
shutil.move(tmp_file, __auth_acl_file)
except IOError, e:
elog("Cannot open ACL file '%s' for read/write access. Error: %s" % (tmp_file, e))
return RhResponse(500, "Internal server error")
return None
def _message_check(http_req, mandatory_elems, optional_elems, error_prefix):
"""
Perform sanity checking on message body.
The message body is assumed to contain a dictionary.
The message object is read from the http_req object. The
function returns a tuple consiting of the validated message
object and - in case of error - an RhResponse object that
can be returned by the calling function. If the validation
is ok, then None will be returned as the second element of
the return tuple.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@param mandatory_elems: List of mandatory message elements.
@type mandatory_elems: list
@param optional_elems: List of optional message elements.
@type optional_elems: list
@param error_prefix: A string that is preprended to the
various error messages, so that they
can reflect the current situation.
@type error_prefix: string
@return: Tuple consisting of message object
(the dictionary) as well as an
RhResponse object in case of validation
error, or None if all is ok.
@rtype: tuple
"""
# Get the object represented in the message body
http_req.make_input_rp()
try:
mesg = http_req.input.next()
except StopIteration:
server.log(snap_log.LEVEL_ERR, "%s request '%s' had no message body" % (error_prefix, http_req.path))
return (None, RhResponse(HttpRequest.BAD_REQUEST, "%s request had no message body" % error_prefix))
# Sanity check the contents of the message body
allowable_elems = mandatory_elems + optional_elems
message_elems = mesg.keys()
for me in mandatory_elems:
if me not in message_elems:
return (mesg, RhResponse(HttpRequest.BAD_REQUEST, "%s request does not contain mandatory element '%s'." % (error_prefix, me)))
for me in message_elems:
if me not in allowable_elems:
return (mesg, RhResponse(HttpRequest.BAD_REQUEST, "%s request contains illegal element '%s'." % (error_prefix, me)))
# All is good, so we don't return a response object
return (mesg, None)
def _get_clear_text_password(msg, error_prefix, need_password=True):
"""
Extract clear text password from auth POST or PUT message.
The password may be obfuscated or in clear text. Or it may
not be present at all. This function handles all that and
accordingly returns the clear-text password or None.
@param msg: Dictionary with message body elements.
@type msg: dict
@param error_prefix: A string that is preprended to the
various error messages, so that they
can reflect the current situation.
@type error_prefix: string
@param need_password: Flag indicating whether the presence of the password
element is mandatory or not. If not than its absence
will not result in this function returning an error.
@type need_password: bool
@return: Tuple consisting of clear-text password if password
was specified or None as first element. In case of
error an RhResponse object will be returned as the
second element of the tuple. If the second element
is None then all was fine.
@rtype: tuple
"""
# Handle the case that the message didn't contain a password at all
if not 'password' in msg:
if need_password:
return (None,
RhResponse(HttpRequest.BAD_REQUEST, "%s request requires 'password' element." % error_prefix))
else:
# No password, no error
return (None, None)
# Check for password obfuscation and produce clear-text version of
# password if necessary.
password_is_obfuscated = True
if "password_obfuscated" in msg:
val = msg['password_obfuscated'].lower()
if val == "no":
password_is_obfuscated = False
elif val != "yes":
return (None,
RhResponse(HttpRequest.BAD_REQUEST, "%s request contains illegal value '%s' for 'password_obfuscated' element." % (error_prefix, val)))
if password_is_obfuscated:
clear_text_password = snap_crypt.deobfuscate(msg['password'])
if not clear_text_password:
return (None,
RhResponse(HttpRequest.BAD_REQUEST, "%s request contains alledgedly obfuscated password, which cannot be de-obfuscated." % error_prefix))
else:
clear_text_password = msg['password']
return (clear_text_password, None)
def auth_get_handler(http_req):
"""
Handle GET requests to the auth module.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
# If this is a blank GET request to the top auth URI then we just
# return the map of all the sub URIs that we support.
if http_req.path == uri_prefix.AUTH or http_req.path == uri_prefix.AUTH + "/":
serv = server.public_uri
menu = {}
descriptions = {
keys.SERVER_AUTH_CHECK : "Check a single user's credentials",
keys.SERVER_AUTH_USER_ENTRY : "Access/modify/delete a single user's entry",
keys.SERVER_AUTH_USER_LIST : "Retrieve complete list of usernames, one per line",
keys.SERVER_AUTH_GROUP_ENTRY : "Access/modify/delete a single group's definition",
keys.SERVER_AUTH_GROUP_LIST : "Retrieve complete list of groups, one per line",
keys.SERVER_AUTH_ACL_ENTRY : "Access/modify/delete a single ACL's definition",
keys.SERVER_AUTH_ACL_LIST : "Retrieve complete list of ACLs, one per line",
}
for k in descriptions.keys():
# The keys are of the form 'auth_....'. But the 'auth_' prefix is not necessary
# in the URIs, since we there have the 'auth/...' prefix already. So, we strip
# the 'auth_' off from the start of the key. The keys for auth have this common
# prefix, so that we can avoid name collisions on the key names, which otherwise
# would be very ordinary and common words (list, entry, check), which have a high
# likelihood of colliding with something sooner or later.
# Also, the keys use a '_' in their names. By simply replacing this with a '/' we
# get the matching URIs.
uri = serv + uri_prefix.AUTH + "/" + k[len(keys.SERVER_AUTH_KEY_PREFIX):].replace("_", "/")
menu[k] = { 'uri' : uri, 'description' : descriptions[k] }
return RhResponse(200, menu, None, { 'title' : product_prefix.SNAPLOGIC_PRODUCT + ': Auth' })
def auth_check(http_req):
"""
Checks whether the user exists and the password is correct.
Our existing authentication mechanism will add a couple of
fields to the http_request structure after it is done checking
the user and password. Specifically, if a user has been
successfully authenticated, it will add the group 'known' to
the group list.
The body of the message either will contain the string 'KNOWN'
or 'UNKNKOWN' depending on this.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
if "known" in http_req.groups:
return RhResponse(200, keys.KNOWN)
else:
return RhResponse(200, keys.UNKNOWN)
def user_list(http_req):
"""
Return the list of users.
For each user a small dictionary is returned, containing the
URI and the description of the user, so that a second request
may be used to retrieve more information about the user.
{
"foobar" : {
"uri" : "/__snap__/auth/user/entry/foobar",
"description" : "Just some user."
},
"blah" : {
"uri" : "/__snap__/auth/user/entry/blah",
"description" : "Some other user..."
}
}
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
with __auth_lock:
uname_list = __user_lookup.keys()
if not __user_lookup.keys():
uname_list = "No users are defined."
else:
uname_list = {}
for uname in __user_lookup:
u = __user_lookup[uname]
uname_list[uname] = { "uri" : uri_prefix.AUTH_USER_ENTRY + "/" + uname, "description" : u.description, }
return RhResponse(200, uname_list, None, { 'title' : product_prefix.SNAPLOGIC_PRODUCT + ': Auth - User list' })
def user_entry_get(http_req):
"""
Return information about a single user.
Returns all the user's information as a dictionary:
{
"name" : "...", # Name of the user
"groups" : [ ... ], # If not member of any groups, this is an empty list
"password" : "...", # Hashed password
"description" : "...", # If not defined this will be an empty string
"email" : "...", # If not defined this will be an empty string
"genid" : "..." # The gen-id needs to be presented to the server for any updates
}
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
# The last path element of the request URI is desired username.
path_elems = http_req.path.split("/")
username = path_elems.pop()
if not username:
return RhResponse(http_req.BAD_REQUEST, "User get request URI '%s' doesn't specify a user as last path element." % (http_req.path))
if http_req.username != username and http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin or the specified user can do this.")
with __auth_lock:
# A user can only be shown if it exists already.
if username not in __user_lookup:
return RhResponse(http_req.NOT_FOUND, "User get request for non existing user '%s'." % username)
# Prepare the response object.
ue = __user_lookup[username]
msg = { "name" : username,
"groups" : ue.groups,
"password" : ue.password,
"description" : ue.description,
"email" : ue.email,
"genid" : ue.genid
}
return RhResponse(200, msg)
def user_entry_post(http_req):
"""
Create a new user entry.
The body should be a dictionary with the following elements:
{
"name" : "<username>",
"password" : "<password>", # obfuscated by default
"password_obfuscated" : "yes|no", # optional
"description" : "<description>", # optional
"email" : "<email>", # optional
}
Note that the last path element also is the username. Thus,
the last path element and the value of the 'name' in the
message dictionary have to be identical!
The 'password' is either given in clear text or it is obfuscated.
The optional 'password_obfuscated' element in the message body
can be used to indicate whether the password is in clear text or
not. By default, the password is assumed to be obfuscated using
the functions provided in snap_crypt.py.
If the user exists already an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# Perform simple validation on the message body
(msg, err_response) = _message_check(http_req, [ "name", "password" ], [ "password_obfuscated", "description", "email" ], "User create")
if err_response:
# An error occured in our validation of the request message
return err_response
username = msg['name']
if ' ' in username:
return RhResponse(http_req.BAD_REQUEST, "A username cannot contain whitespace.")
# The last path element of the request URI is the username. Therefore, the
# last path element should be the same as the username specified in the
# message body.
if not http_req.path.endswith("/" + username):
return RhResponse(http_req.BAD_REQUEST, "User create request URI '%s' doesn't match specified username '%s'." % (http_req.path, username))
with __auth_lock:
# A new user can only be created if there is no user with that name
# already. Check that this user doesn't exist.
if username in __user_lookup:
return RhResponse(http_req.CONFLICT, "User create request was sent for already existing user '%s'." % username)
if not is_pe():
# A user can only be created if we haven't reached the user-limit yet (implemented only in the CE)
# We count how many user there are besides a small number of system users.
c = 0
for u in __user_lookup:
if u not in CE_SYSTEM_USERS:
c += 1
# Terminate with error if we already have reached the user account limit with the currently existing users.
# Obviously, no user can be created in that case.
if c >= CE_USER_LIMIT:
return RhResponse(http_req.UNAUTHORIZED, "SnapLogic %s Edition maximum number of user accounts exceeded." % server_edition)
# Get clear-text password
(clear_text_password, err_response) = _get_clear_text_password(msg, "User create")
if err_response:
# An error occured while extracting the clear-text password
return err_response
# Looks like all is well with the request. We can
# create a new entry in the internal user list.
#
# Why cast 'clear_text_password' to a string? Because so far it has been
# some unicode contraption, which md5crypt doesn't handle.
hashed_password = snap_crypt.md5crypt(str(clear_text_password), snap_crypt.make_salt())
# Extract optional parameters with safe defaults (empty string)
description = msg.get("description", "")
email = msg.get("email", "")
# Create new user entry in our internal user/password lookup table.
# When a user is created, it does not belong to any groups.
__user_lookup[username] = __user(hashed_password, description, email)
genid = __user_lookup[username].genid
# Modified user data needs to be backed up
err_ret = _store_user_db()
if err_ret:
return err_ret
return RhResponse(200, (http_req.path, genid, "User '%s' successfully created." % username))
def user_entry_put(http_req):
"""
Edit an existing user entry.
The body should be a dictionary with the following elements:
{
"genid" : "<genid>",
"name" : "<username>",
"password" : "<password>", # obfuscated by default, optional
"password_obfuscated" : "yes|no" # optional
"description" : "<description>", # optional
"email" : "<email>", # optional
}
Note that the last path element also is the username. Thus,
the last path element and the value of the 'name' in the
message dictionary have to be identical!
The 'genid' argument needs to match the value that the server
is currently holding in memory. This value can be retrieved
via user_entry_get().
Contrary to POST (see the user_entry_post() function), only the
username is a required element. All other elements are optional.
This allows the setting of only those elements that need changing.
Specifically, the password does not need to be resent every time.
The 'password' is either given in clear text or it is obfuscated.
The optional 'password_obfuscated' element in the message body
can be used to indicate whether the password is in clear text or
not. By default, the password is assumed to be obfuscated using
the functions provided in snap_crypt.py.
If the user does not exist already then an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup, __password_cache
# Perform simple validation on the message body
(msg, response) = _message_check(http_req, [ "genid", "name" ], [ "password", "password_obfuscated", "description", "email" ], "User edit")
if response:
# An error occured in our validation of the request message
return response
username = msg['name']
if http_req.username != username and http_req.username != ADMIN_USER:
return RhResponse(401, "Only admin or the specified user can do this.")
genid = msg['genid']
try:
dummy = int(genid)
except:
return RhResponse(400, "Malformed gen-ID in user edit request.")
# The last path element of the request URI is the username. Therefore, the
# last path element should be the same as the username specified in the
# message body.
if not http_req.path.endswith("/" + username):
return RhResponse(http_req.BAD_REQUEST, "User edit request URI '%s' doesn't match specified username '%s'." % (http_req.path, username))
with __auth_lock:
# The specified user must exist already
ue = __user_lookup.get(username)
if not ue:
return RhResponse(http_req.NOT_FOUND, "User edit request was sent for non-existing user '%s'." % username)
# Ensure that the client has the latest version of the entry
if genid != ue.genid:
return RhResponse(http_req.CONFLICT, "Invalid gen-ID specified for user edit request.", ERROR_GEN_ID_CONFLICT)
# Get clear-text password. Note that the password is optional, no error if it doesn't exist
(clear_text_password, err_response) = _get_clear_text_password(msg, "User create", False)
if err_response:
# An error occured while extracting the clear-text password
return err_response
if clear_text_password:
# We are only here if a password was specified at all.
# Why cast 'clear_text_password' to a string? Because so far it has been
# some unicode contraption, which md5crypt doesn't handle.
ue.password = snap_crypt.md5crypt(str(clear_text_password), snap_crypt.make_salt())
# Update the entry for the user in our password cache
if username in __password_cache:
__password_cache[username] = clear_text_password
if 'description' in msg:
ue.description = msg['description']
if 'email' in msg:
ue.email = msg['email']
ue.genid = _increase_genid(ue.genid)
# Modified user data needs to be backed up
err_ret = _store_user_db()
if err_ret:
return err_ret
return RhResponse(200, (http_req.path, ue.genid, "User '%s' successfully edited." % username))
def user_entry_delete(http_req):
"""
Delete an existing user entry.
If the user does not exist an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup, __password_cache
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# The last path element of the request URI is desired username.
path_elems = http_req.path.split("/")
username = path_elems.pop()
if not username:
return RhResponse(http_req.BAD_REQUEST, "User delete request URI '%s' doesn't specify a user as last path element." % (http_req.path))
if username == ADMIN_USER:
return RhResponse(http_req.BAD_REQUEST, "The admin user cannot be deleted.")
with __auth_lock:
# A user can only be deleted if it exists already.
ue = __user_lookup.get(username)
if not ue:
return RhResponse(http_req.NOT_FOUND, "User delete request for non existing user '%s'." % username)
# Check if this user is referred to in any ACL. If so, the client needs
# to modify the ACL first.
acl_list = []
for aname in __acl_rules:
(rules, description, genid) = __acl_rules[aname]
for r in rules:
if r['principal_type'] == "user":
if username in r['principal']:
if aname not in acl_list:
# A principal can appear multiple times in a rule definition set, so we only
# need to append the name the first time
acl_list.append(aname)
if acl_list:
return RhResponse(http_req.BAD_REQUEST, "User '%s' still appears in ACL%s '%s' and can therefore not be deleted." %
(username, 's' if len(acl_list) > 1 else '', ', '.join(acl_list)))
# The user also needs to be removed from all groups it was a member in...
ugroups = ue.groups
for g in ugroups:
__groups[g].del_user(username)
__groups[g].genid = _increase_genid(__groups[g].genid)
# ... and of course the user lookup table as well as the password cache.
del __user_lookup[username]
if username in __password_cache:
del __password_cache[username]
#
# Hm, yeah. These two updates should really be one transaction.
# I guess they will become that once this is all stored in the
# repository. For now, this is what we have.
#
# Modified user data needs to be backed up
err_ret = _store_user_db()
if err_ret:
return err_ret
# Modified group data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, "User '%s' successfully deleted." % username)
def group_list(http_req):
"""
Return the list of groups.
For each group a small dictionary is returned that contains the description and the
URI for the group, so that more details can be retrieved in a second request.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
with __auth_lock:
if not __groups:
group_list = "No groups are defined."
else:
group_list = {}
for gname in __groups:
g = __groups[gname]
group_list[gname] = { "uri" : uri_prefix.AUTH_GROUP_ENTRY + "/" + gname, "description" : g.description }
return RhResponse(200, group_list, None, { 'title' : product_prefix.SNAPLOGIC_PRODUCT + ': Auth - Group list' })
def group_entry_get(http_req):
"""
Return information about a single group.
Returns all the group's information as a dictionary:
{
"name" : "...",
"users" : [ ... ], # May be an empty list
"description" : "...",
"genid" : "..."
}
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
# The last path element of the request URI is desired groupname.
path_elems = http_req.path.split("/")
groupname = path_elems.pop()
if not groupname:
return RhResponse(http_req.BAD_REQUEST, "Group get request URI '%s' doesn't specify a group as last path element." % (http_req.path))
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
with __auth_lock:
# A group can only be shown if it exists already.
if groupname not in __groups:
return RhResponse(http_req.NOT_FOUND, "Group get request for non existing group '%s'." % groupname)
# Prepare the response object.
gr = __groups[groupname]
msg = { "name" : groupname,
"users" : gr.users,
"description" : gr.description,
"genid" : gr.genid
}
return RhResponse(200, msg)
def group_entry_post(http_req):
"""
Create a new user group.
The body should be a dictionary with the following elements:
{
"name" : "<groupname>",
"users" : [ <username_1>, <username_2>, ... ]
"description" : "<description>", # optional
}
Note that the last path element also is the name. Thus,
the last path element and the value of the 'groupname' in the
message dictionary have to be identical!
If the group exists already then an error will be returned.
If any of the specified users do not exist in the system already
then the entire operation fails and an error will be returned.
The list of users may be empty.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# Perform simple validation on the message body
(msg, err_response) = _message_check(http_req, [ "name", "users" ], [ "description" ], "Group create")
if err_response:
# An error occured in our validation of the request message
return err_response
groupname = msg['name']
if ' ' in groupname:
return RhResponse(http_req.BAD_REQUEST, "A groupname cannot contain whitespace.")
description = msg.get('description', "")
# The last path element of the request URI is the group. Therefore, the
# last path element should be the same as the groupname specified in the
# message body.
if not http_req.path.endswith("/" + groupname):
return RhResponse(http_req.BAD_REQUEST, "Group create request URI '%s' doesn't match specified groupname '%s'." % (http_req.path, groupname))
with __auth_lock:
# A new group can only be created if there is no group with that name
# already. Check that this group doesn't exist.
if groupname in __groups:
return RhResponse(http_req.CONFLICT, "Group create request was sent for already existing group '%s'." % groupname)
# All the users specified in the request must exist already.
not_present = [ u for u in msg['users'] if u not in __user_lookup ]
if not_present:
return RhResponse(http_req.BAD_REQUEST, "Group create request failed because non-existing user(s): '%s'." % ', '.join(not_present))
# Looks like all is well with the request. We can
# create a new entry in the internal groups list.
# Create new group and modify existing user entries to contain the
# new group as well.
__groups[groupname] = __group(msg['users'], description)
genid = __groups[groupname].genid
for username in msg['users']:
__user_lookup[username].add_group(groupname)
__user_lookup[username].genid = _increase_genid(__user_lookup[username].genid)
# Modified group data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, (http_req.path, genid, "Group '%s' successfully created." % groupname))
def group_entry_put(http_req):
"""
Edit an existing group entry.
The body should be a dictionary with the following elements:
{
"genid" : "<genid>",
"name" : "<groupname>",
"users" : [ <username_1>, <username_2>, ... ] # optional
"description" : "<description>", # optional
}
Note that the last path element also is the groupname. Thus,
the last path element and the value of the 'groupname' in the
message dictionary have to be identical!
The 'genid' argument needs to match the value that the server
is currently holding in memory. This value can be retrieved
via group_entry_get().
If the group does not exist already then an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup
global __groups
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# Perform simple validation on the message body
(msg, err_response) = _message_check(http_req, [ "genid", "name" ], [ "users", "description" ], "Group edit")
if err_response:
# An error occured in our validation of the request message
return err_response
groupname = msg['name']
new_users = msg.get('users', None)
description = msg.get('description', None)
genid = msg['genid']
try:
dummy = int(genid)
except:
return RhResponse(400, "Malformed gen-ID in group edit request.")
# The last path element of the request URI is the group. Therefore, the
# last path element should be the same as the groupname specified in the
# message body.
if not http_req.path.endswith("/" + groupname):
return RhResponse(http_req.BAD_REQUEST, "Group edit request URI '%s' doesn't match specified groupname '%s'." % (http_req.path, groupname))
with __auth_lock:
# A new group can only be created if there is no group with that name
# already. Check that this user doesn't exist.
gr = __groups.get(groupname)
if not gr:
return RhResponse(http_req.NOT_FOUND, "Group edit request was sent for non-existing group '%s'." % groupname)
# Ensure that the client has the latest version of the entry
if genid != gr.genid:
return RhResponse(http_req.CONFLICT, "Invalid gen-ID specified for group edit request.", ERROR_GEN_ID_CONFLICT)
if new_users is not None:
# Sanity check the new user list: It can only contain existing users
not_present = [ u for u in new_users if u not in __user_lookup ]
if not_present:
return RhResponse(http_req.BAD_REQUEST, "Group edit request failed because non-existing user(s): '%s'." % ', '.join(not_present))
# We need to find the difference between the new user set and the
# old user set of this group, so that we know which user entries
# to modify.
old_users = gr.users
new_users = msg['users']
gr.replace_users(new_users)
remove_users = [ u for u in old_users if u not in new_users ] # The users we need to remove from this group
add_users = [ u for u in new_users if u not in old_users ] # The users we need to add to this group
for u in remove_users:
# Every user that is not in this group anymore but used to be now needs to be modified
# and have this group taken out of its personal groups list
__user_lookup[u].del_group(groupname)
__user_lookup[u].genid = _increase_genid(__user_lookup[u].genid)
for u in add_users:
# Every user that is being added to this group needs to be modified and have
# this group added to its personal groups list
__user_lookup[u].add_group(groupname)
__user_lookup[u].genid = _increase_genid(__user_lookup[u].genid)
# Modified user data needs to be backed up
err_ret = _store_user_db()
if err_ret:
return err_ret
if description is not None:
gr.description = description
gr.genid = _increase_genid(gr.genid)
# Modified group data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, (http_req.path, gr.genid, "Group '%s' successfully edited." % groupname))
def group_entry_delete(http_req):
"""
Delete an existing group entry.
If the group does not exist an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup, __groups
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# The last path element of the request URI is desired groupname.
path_elems = http_req.path.split("/")
groupname = path_elems.pop()
if not groupname:
return RhResponse(http_req.BAD_REQUEST, "Group delete request URI '%s' doesn't specify a group as last path element." % (http_req.path))
with __auth_lock:
# A group can only be deleted if it exists already.
if groupname not in __groups:
return RhResponse(http_req.NOT_FOUND, "Group delete request for non existing group '%s'." % groupname)
# Check if this group is referred to in any ACL. If so, the client needs
# to modify the ACL first.
acl_list = []
for aname in __acl_rules:
(rules, description, genid) = __acl_rules[aname]
for r in rules:
if r['principal_type'] == "group":
if groupname in r['principal']:
if aname not in acl_list:
# A principal can appear multiple times in a rule definition set, so we only
# need to append the name the first time
acl_list.append(aname)
if acl_list:
return RhResponse(http_req.BAD_REQUEST, "Group '%s' still appears in ACL%s '%s' and can therefore not be deleted." %
(groupname, 's' if len(acl_list) > 1 else '', ', '.join(acl_list)))
# Update the group information for each user that was in this group
users = __groups[groupname].users
for u in users:
__user_lookup[u].del_group(groupname)
__user_lookup[u].genid = _increase_genid(__user_lookup[u].genid)
del __groups[groupname]
# Modified group data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, "Group '%s' successfully deleted." % groupname)
def acl_list(http_req):
"""
Return the list of ACLs.
One group per line. In effect, this is only the list of
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
with __auth_lock:
acl_list = __acl_rules.keys()
if not acl_list:
acl_list = "No ACLs are defined."
else:
acl_list = {}
for name in __acl_rules:
(rules, description, genid) = __acl_rules[name]
acl_list[name] = { "uri" : uri_prefix.AUTH_ACL_ENTRY + name, "description" : description if description is not None else "" }
return RhResponse(200, acl_list, None, { 'title' : product_prefix.SNAPLOGIC_PRODUCT + ': Auth - ACL list' })
def acl_entry_get(http_req):
"""
Return information about a single ACL.
Returns all the ACL's information as a dictionary:
{
"name" : "...", # This is a relative URI.
"description" : "...",
"rules" : [ ... ],
"genid" : "..."
}
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object with data and code
to be written to client.
@rtype: L{RhResponse}
"""
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# The last path elements of the request URI are the desired rulename.
# A rulename is a URI, so "/" is a valid rulename. We cannot just strip
# the last element of the path, since a rulename can have multiple elements
# separated by "/".
try:
i = http_req.path.index(uri_prefix.AUTH_ACL_ENTRY)
except:
# This really shouldn't happen, since we are only executing this function
# here if we found this particular prefix.
return RhResponse(http_req.BAD_REQUEST, "Not proper prefix for request URI.")
rulename = http_req.path[i+len(uri_prefix.AUTH_ACL_ENTRY):]
with __auth_lock:
# A rule can only be shown if it exists already.
if rulename not in __acl_rules:
return RhResponse(http_req.NOT_FOUND, "ACL get request for non existing ACL '%s'." % rulename)
# Prepare the response object.
(rules, description, genid) = __acl_rules[rulename]
# The rules don't look very good when you'd just dump them out of the data structure.
# Therefore, we assemble the information we have about each rule into a more appealing
# looking string.
rule_strings = __make_rule_strings(rules)
msg = { "name" : rulename,
"description" : "" if description is None else description,
"genid" : genid,
"rules" : rule_strings
}
return RhResponse(200, msg)
def __parse_rule_bodies(http_req, rules, rule_list, err_prefix):
"""
Perform parsing of ACL rules in rule create and edit requests.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@param rules: List of textual rule descriptions.
@type rules: list
@param rule_list: The output list of parsed rules, transformed
into the internal data structures we use
for their representations. Should be empty
when passed in.
@type rule_list: list
@param err_prefix: A string that should be pre-pended to any error
messages.
@type err_prefix: string
@return: An L{RhResponse} object in case of error,
otherwise None.
@return L{RhResponse}
"""
# In the request the rules for this ACL are given as strings.
# We need to translate this into specific data structures.
for rule_str in rules:
d = {}
# The format of a rule strings is as follows:
# ALLOW (GROUP|USER) name [name]* PERMISSION read write execute
# or
# DENY (GROUP|USER) name [name]*
e = rule_str.lower().split()
try:
if e[0] in [ "allow", "deny" ]:
d['verb'] = e[0]
else:
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': Needs to start with 'allow' or 'deny' keyword." % (err_prefix, rule_str))
if e[1] in [ "user", "group" ]:
d['principal_type'] = e[1]
else:
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': Needs to contain 'user' or 'group' keyword in second position." %
(err_prefix, rule_str))
try:
permission_index = e.index("permission")
if d['verb'] == "deny":
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': A deny rule cannot contain the 'permission' keyword." % (err_prefix, rule_str))
except:
if d['verb'] == "allow":
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': It does not contain the 'permission' keyword." % (err_prefix, rule_str))
if d['verb'] == "allow":
if permission_index == 2:
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': Does not specify %s after '%s' keyword." % (err_prefix, rule_str, e[1], e[1]))
d['principal'] = e[2:permission_index]
else:
# 'deny' rules have nothing after the list of principals
d['principal'] = e[2:]
for n in d['principal']:
if d['principal_type'] == "user":
lookup_table = __user_lookup
else:
lookup_table = __groups.keys() + [ 'known', 'unknown', 'public' ]
if n not in lookup_table:
return RhResponse(http_req.BAD_REQUEST, "%s request rule '%s' refers to non-existent %s '%s'." % (err_prefix, rule_str, d['principal_type'], n))
if d['verb'] == "allow":
d['permissions'] = e[permission_index+1:]
if not d:
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': No permissions are specified." % (err_prefix, rule_str))
for p in d['permissions']:
if p not in perm_lookup:
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s': Unknown permission '%s'." % (err_prefix, rule_str, p))
except:
return RhResponse(http_req.BAD_REQUEST, "%s request contains malformed rule '%s'." % (err_prefix, rule_str))
rule_list.append(d)
# All went well, no error to report
return None
def acl_entry_post(http_req):
"""
Create a new ACL.
The body should be a dictionary with the following elements:
{
"name" : "<aclname>",
"description" : "<description>", # optional
"rules" : [ "rule1", "rule2" ... ]
}
Note that the last path elements are the ACL name. Thus,
the last path elements and the value of the 'name' in the
message dictionary have to be identical!
If the ACL exists already then an error will be returned.
The list of rules may be empty, but must be present.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup, __acl_rules
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# The last path elements of the request URI are the desired rulename.
# A rulename is a URI, so "/" is a valid rulename. We cannot just strip
# the last element of the path, since a rulename can have multiple elements
# separated by "/".
try:
i = http_req.path.index(uri_prefix.AUTH_ACL_ENTRY)
except:
# This really shouldn't happen, since we are only executing this function
# here if we found this particular prefix.
return RhResponse(http_req.BAD_REQUEST, "Not proper prefix for request URI.")
rulename = http_req.path[i+len(uri_prefix.AUTH_ACL_ENTRY):]
if rulename.endswith("/") and rulename != "/":
# To avoid confusion and ambiguities, we don't allow any of the rule names
# to end with a '/', unless of course, this is a rule for '/' itself.
return RhResponse(http_req.BAD_REQUEST, "ACL create request URI '%s' ends with '/'." % rulename)
# Perform simple validation on the message body
(msg, err_response) = _message_check(http_req, [ "name", "rules" ], [ "description" ], "ACL create")
if err_response:
# An error occured in our validation of the request message
return err_response
name = msg['name']
rules = msg['rules']
description = msg.get('description', "")
# The last path elements of the request URI also is the name of the ACL.
# Therefore, the last path elements should be the same as the name specified
# in the message body.
if rulename != name:
return RhResponse(http_req.BAD_REQUEST, "ACL create request URI '%s' doesn't match specified ACL name '%s'." % (http_req.path, name))
with __auth_lock:
# A new ACL can only be created if there is no ACL with that name
# already. Check that this ACL doesn't exist.
if name in __acl_rules:
return RhResponse(http_req.CONFLICT, "ACL create request was sent for already existing ACL '%s'." % name)
# In the request the rules for this ACL are given as strings.
# We need to translate this into specific data structures.
rule_list = []
ret = __parse_rule_bodies(http_req, rules, rule_list, "ACL create")
if ret:
# Some error has happened.
return ret
# We can now store the new ACL definition and update our fast lookup table
__acl_rules[name] = ( rule_list, description, _make_start_genid())
__prepare_fast_permission_lookup(__acl_rules)
# Modified ACL data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, (http_req.path, _make_start_genid(), "ACL '%s' successfully created." % name))
def acl_entry_put(http_req):
"""
Edit an already existing ACL.
The body should be a dictionary with the following elements:
{
"genid" : <genid>,
"name" : "<aclname>",
"description" : "<description>", # optional
"rules" : [ "rule1", "rule2" ... ] # optional
}
Note that the last path elements are the ACL name. Thus,
the last path elements and the value of the 'name' in the
message dictionary have to be identical!
If the ACL does not exist already then an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __user_lookup, __acl_rules
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# The last path elements of the request URI are the desired rulename.
# A rulename is a URI, so "/" is a valid rulename. We cannot just strip
# the last element of the path, since a rulename can have multiple elements
# separated by "/".
try:
i = http_req.path.index(uri_prefix.AUTH_ACL_ENTRY)
except:
# This really shouldn't happen, since we are only executing this function
# here if we found this particular prefix.
return RhResponse(http_req.BAD_REQUEST, "Not proper prefix for request URI.")
rulename = http_req.path[i+len(uri_prefix.AUTH_ACL_ENTRY):]
# Perform simple validation on the message body
(msg, err_response) = _message_check(http_req, [ "name", "genid" ], [ "description", "rules" ], "ACL edit")
if err_response:
# An error occured in our validation of the request message
return err_response
name = msg['name']
genid = msg['genid']
rules = msg.get('rules') # May not be defined, so None if it doesn't exist
# The last path elements of the request URI also is the name of the ACL.
# Therefore, the last path elements should be the same as the name specified
# in the message body.
if rulename != name:
return RhResponse(http_req.BAD_REQUEST, "ACL edit request URI '%s' doesn't match specified ACL name '%s'." % (http_req.path, name))
with __auth_lock:
# The specified ACL must exist already
if name not in __acl_rules:
return RhResponse(http_req.NOT_FOUND, "ACL edit request was sent for non-existing ACL '%s'." % name)
(old_rules, old_description, old_genid) = __acl_rules[name]
# Ensure that the client has the latest version of the entry
if genid != old_genid:
return RhResponse(http_req.CONFLICT, "Invalid gen-ID specified for ACL edit request.", ERROR_GEN_ID_CONFLICT)
# If nothing new was defined for 'description' then we will just
# use the old value.
description = msg.get('description', old_description)
if 'rules' in msg:
# In the request the rules for this ACL are given as strings.
# We need to translate this into specific data structures.
rule_list = []
ret = __parse_rule_bodies(http_req, rules, rule_list, "ACL edit")
if ret:
# Some error has happened.
return ret
else:
# If no new rules were defined, we just use whatever was there
# before.
rule_list = old_rules
# We can now store the edited ACL definition and update our fast lookup table
genid = _increase_genid(genid)
__acl_rules[name] = ( rule_list, description, genid)
__prepare_fast_permission_lookup(__acl_rules)
# Modified ACL data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, (http_req.path, genid, "ACL '%s' successfully edited." % name))
def acl_entry_delete(http_req):
"""
Delete an existing ACL entry.
If the ACL does not exist an error will be returned.
@param http_req: HTTP request object.
@type http_req: L{HttpRequest}
@return: RhResponse object.
@rtype: L{RhResponse}
"""
global __acl_rules
if http_req.username != ADMIN_USER:
return RhResponse(http_req.UNAUTHORIZED, "Only admin can do this.")
# The last path elements of the request URI are the desired rulename.
# A rulename is a URI, so "/" is a valid rulename. We cannot just strip
# the last element of the path, since a rulename can have multiple elements
# separated by "/".
try:
i = http_req.path.index(uri_prefix.AUTH_ACL_ENTRY)
except:
# This really shouldn't happen, since we are only executing this function
# here if we found this particular prefix.
return RhResponse(http_req.BAD_REQUEST, "Not proper prefix for request URI.")
rulename = http_req.path[i+len(uri_prefix.AUTH_ACL_ENTRY):]
with __auth_lock:
# An ACL can only be deleted if it exists already.
if rulename not in __acl_rules:
return RhResponse(http_req.NOT_FOUND, "ACL delete request for non existing ACL '%s'." % rulename)
del __acl_rules[rulename]
# Modified ACL data needs to be backed up
err_ret = _store_groups_and_acls()
if err_ret:
return err_ret
return RhResponse(200, "ACL '%s' successfully deleted." % rulename)
def initialize(type, db_name, passwds):
"""
Initialize the authentication module.
We set up our own loggers, process the authentication database,
which could be a file, for example, and also read the username
and passwords. The raw auth information is then processed to
create fast lookup data structures, which allow quick processing
of incoming requests at a later time.
@param type: Indicates the type of authentication DB.
Currently supported: 'file'.
@type type: string
@param db_name: Identifies the DB for the specified type.
For example, if 'type' is 'file' then 'db_name'
is the filename.
@type db_name: string
@param passwds: Identifies the password DB. If the type is 'file'
then the passwords are expected to be contained
in a file as well.
@type passwds: string
"""
global log, elog, rlog, __user_lookup, __password_file, __acl_rules, __auth_acl_file
__password_file = None
# Initialize loggers, which are used by the auth module.
(log, elog, rlog) = server.logger.make_specific_loggers(snap_log.LOG_AUTH)
# Do processing based on the type of auth DB.
# 'file' is one type of auth DB...
if not type:
log(snap_log.LEVEL_INFO, "No authentication DB information was provided.")
return
if type == 'file':
__auth_acl_file = db_name
access_parser = __process_auth_type_file(db_name, passwds)
__password_file = passwds
else:
raise snap_exceptions.SnapGeneralError("Unsupported auth DB type '%s'." % type)
__acl_rules = access_parser.get_rules() # Store for easy access to non-compacted, readable rules.
for a in __acl_rules:
(rules, description) = __acl_rules[a]
__acl_rules[a] = (rules, description, _make_start_genid())
__process_user_groups(access_parser)
__prepare_fast_permission_lookup(__acl_rules)
# Warn against missing access rights to certain important URIs.
# For example, "/" and "/__snap__/meta/info" need to be accessed
# when you want to connect to a server.
_warn_missing_access_rights()
if not __path_rules:
raise snap_exceptions.SnapFormatError("No path rules have been defined.")
"""
print " ================ user lookup"
print __user_lookup
print " ================ path rules"
print __path_rules
print " ================ prefix list"
print __prefix_list
"""
def _warn_missing_access_rights():
"""
Warn if users can't connect to server because of access rights.
"""
# Iterate through all users and check against the
# well known paths
for uname in __user_lookup:
ue = __user_lookup[uname]
for u in __need_access_uri:
perms = check_uri(u, uname, ue.groups + [ "public", "known" ])
if not perms & (PERM_READ | PERM_EXEC):
log(snap_log.LEVEL_WARN, "User '%s' will not be able to connect to server because of insufficient access rights to '%s'." % (uname, u))
continue
def __process_auth_type_file(db_name, passwds):
"""
Read an auth config file and username/password file.
As side effects, this initializes a number of module level variables.
@param db_name: Identifies the auth config file.
@type db_name: string
@param passwds: Identifies the password file.
@type passwds: string
@return: The access parser object.
@rtype: L{AccessParser}
"""
# Parse the username/password file and create local user/password list
try:
f = open(passwds, "r")
except Exception, e:
raise snap_exceptions.SnapIOError("Cannot find password file '%s'" % passwds)
try:
for (line_number, line) in enumerate(f):
line = line.strip()
if not line.startswith("#") and len(line)>0:
#(user, passw) = line.split(":", 3)
elems = line.split(":", 3)
if len(elems) == 2:
# Old style entry doesn't have email or description
email = ""
descr = ""
else:
# New style entry also has email and description
email = elems[2]
descr = elems[3]
user = elems[0]
passw = elems[1]
# We create a user object for our in-memory cache, which will contain
# not only all the defined attribuutes of the user, but also the groups
# to which it belongs for quick reference.
__user_lookup[user] = __user(passw, descr, email)
except Exception, e:
raise snap_exceptions.SnapFormatError("Format error in line %d of password file '%s'" % (line_number+1, passwds))
# Create the data structures for access and permission parsing from the auth config file.
# The list of user names that we just read from the password file is passed into this
# parser, since those users are refered to in the access config file. Users once were
# defined in that file as well, but we want to make sure they only need to be specified
# in a single location. The user/password file is it, so now the list of users is just
# passed into the access parser.
access_parser = XmlAccessParser(db_name, __user_lookup.keys())
return access_parser
def __process_user_groups(access_parser):
"""
Add the user's groups to the global lookup table.
@param access_parser: The access parser object.
@type access_parser: L{AccessParser}
"""
user_groups = access_parser.get_users_and_groups()
# We also add the groups for each user into its password
# table, so that we only need to do a single lookup when
# a request arrives.
# At the same time, we are preparing a reverse lookup table,
# where we can use a group name to find the users within that
# group.
global __groups
for user in user_groups.keys():
groups = user_groups[user]
# Attaching the groups to the user...
if user in __user_lookup:
for g in groups:
__user_lookup[user].add_group(g)
# ... doing the reverse: Making groups with users
for g in groups:
if not g in __groups:
__groups[g] = __group(users=[ user ])
else:
__groups[g].add_user(user)
def __prepare_fast_permission_lookup(rules_dict):
"""
Prepare a fast lookup structure for permissions based on user/groups/URI.
The goal here is to create a dictionary (indexed by path) of tuples, each
tuple containing two further dictionaries (indexed by user-name and group-
name, respectively). And for each of those users or groups we then have
the permission bitmask stored. Not all users or groups may be present.
The result dictionary will be stored in the global __path_rules
So, if I have a path /foo/bar and I want to find the permissions for a
given user for that path, I can just do:
perm = __path_rules["/foo/bar"][USER_PATH_RULE][username]
And a lookup for a group would be accordingly:
perm = __path_rules["/foo/bar"][GROUP_PATH_RULE][groupname]
Note that you may get exceptions if you do that, because as mentioned,
not all possible paths/users/groups combinations need to be present.
Permissions are accumulative. So, for example, you can either write
this:
allow user foo permission read write
Or you can write this:
allow user foo permission read
allow user foo permission write
They are equivalent.
But note further that a 'deny' statement for a user or group ensures that
all permissions are revoked and remain revoked. So, for example:
allow user foo permission read write
deny user foo
allow user foo permission read
When the 'deny' is encountered, it will NOT just 'reset' the permission
to 0, and then leave 'foo' with the read permission once the second allow
statement is encountered. No! Instead, 'foo' will be left denied, and the
second 'allow' is in effect ignored.
@param rules_dict: A dictionary representing the location ACL rules.
@type rules_dict: dict
"""
global __path_rules, __prefix_list
path_rules = {}
for path in rules_dict.keys():
# For each path we are creating separate rule sets,
# one for users (which takes precedence) and one for
# groups. To facilitate faster run-time checking of
# incoming requests, we also crate a set version of
# the group-list.
user_rules = {}
group_rules = {}
group_set = sets.Set()
# This tuple contains in slot 0 (USER_PATH_RULE) the
# permissions for users and in slot 1 (GROUP_PATH_RULE) the
# permissions for groups. Slot 2 (GROUP_NAME_SET) contains
# just a set of the group names, which comes in handy later
# for on-the-fly matching and checking.
path_rules[path] = ( user_rules, group_rules, group_set )
(rules, description, genid) = rules_dict[path]
for rule in rules:
# A rule-set for a path is a list. Each list element (rule)
# may look like this:
#
# { "verb" : "allow",
# "principal_type" : "user",
# "principal" : [ 'juergen', 'chris' ],
# "permissions" : [ "operator", "user" ]
# }
#
# A 'deny' rule does not have any permissions attached.
#
# Let's see which rules we are accumulating here, user or groups
if rule['principal_type'] == 'user':
d = user_rules
elif rule['principal_type'] == 'group':
d = group_rules
if rule['verb'] == 'deny':
for name in rule['principal']:
# If we find a 'deny' rule then we whack whatever is there already for that
# list of principals. We also set a special marker value (-1), which can tell
# us in subsequent iterations that this principal has been denied for this
# rule and no further permissions should be accumulated for it.
d[name] = -1
else: # allow
# Assemble the permission bit set
permission = 0
for p in rule['permissions']:
permission |= perm_lookup[p]
for name in rule['principal']:
if not d.has_key(name):
d[name] = permission
else:
# Only continue permission accumulation if this principal has not been denied
# for this rule before (marked by a -1).
if d[name] != -1:
d[name] |= permission
# Clean up any 'denied' (-1) markers and replace them with the proper permission
# bitset, which indicates: no permission at all (0).
for name in user_rules.keys():
if user_rules[name] == -1:
user_rules[name] = 0
for name in group_rules.keys():
if group_rules[name] == -1:
group_rules[name] = 0
# Now that the group rules dictionary is finalized, we can create the set of
# group names for this rule.
group_set.update(group_rules.keys())
__path_rules = path_rules
"""
Prepare a length-sorted list of prefixes for easy matching.
This takes the prefixes we have in our path rules, and sorts them
by length in a list (longest first). Matching for the longest prefix
of a request then simply entails running through this list.
Once we have found the prefix, we can use it to look up the
rules in our __path_rules list.
"""
__prefix_list = __path_rules.keys()
__prefix_list.sort(lambda x, y: -cmp(len(x), len(y)))
def check_uri(uri, user, groups):
"""
Get the permissions for a given URI and user/groups.
@param uri: The URI that needs to be checked, without the server
component. So, http://foo.com:1234/foo/bar should
just appear as "/foo/bar".
@type uri: string
@param user: The username.
@type user: string
@param groups: The groups that the user is a member of.
@type groups: list
@return: Permissions for this user and URI, as bit set.
@rtype: integer
"""
global __prefix_list, __path_rules, __feed_prefix, __feed_prefix_len
if not __feed_prefix:
# We know the feed prefix is in the config object, because
# it's a mandatory parameter
main_section = server.config.get_section("main")
__feed_prefix = main_section.get('pipe_to_http_uri_prefix')
__feed_prefix_len = len(__feed_prefix)
if uri.startswith(__feed_prefix):
# Pipe-to-HTTP URIs start with a specific prefix (__feed_prefix).
# They need to be special cased: We want the same ACLs that apply
# to the resource itself be applied to the feed URI. So, for example:
# Resource URI: /foo/bar
# Output view: out
# Feed URI: /feed/foo/bar/out
# Whatever ACLs we have set for /foo/bar should also apply to
# /feed/foo/bar/.... Therefore, when we detect a URI starting with
# the well-known feed prefix we will strip the prefix off the URI,
# as well as the last path component, which specifies the output view.
# We are left with the pure resource URI, which then will be used
# to do the actual ACL check.
if uri.endswith("/"):
# Skip over the last '/', if the URI ends with it...
last_slash = string.rfind(uri, "/", 0, -1)
else:
last_slash = string.rfind(uri, "/")
uri = uri[__feed_prefix_len:last_slash]
# XXX Ticket #1606: If the user is the 'admin' super user, they have all
# XXX permissions on every URI.
if user == ADMIN_USER:
return PERM_READ | PERM_WRITE | PERM_EXEC
# The rules on how to get the permissions for a request are as follows:
# Find the longest prefix match rule that specifically mentions this
# user. If such a rule exists, we use those permissions and are done.
# Otherwise (no specific rule for this user and URI), we go by the
# groups. A user can be a member of multiple groups. So, we go back
# through the sorted prefix list (from longest to shortest) and accumulate
# the permissions for the groups as we encounter them. The list of groups
# that we check against starts out being the complete list of all groups
# that the user is a member in. Everytime we find a group mentioned in
# those rules, we take that rule out of this list, until we are either
# at the end of the prefix list, or don't have any more groups left for
# this user.
remaining_groups = sets.Set(groups)
group_permissions = 0
# Need iterate through the longest-prefix list (longest first).
for prefix in __prefix_list:
if uri.startswith(prefix):
# Check if those rules apply to this user. We know the prefix
# exists in __path_rules, because our __prefix_list was created
# from the __path_rules keys.
pr = __path_rules[prefix]
if pr[USER_PATH_RULE].has_key(user):
# We found a rule exactly for this user! We are done here,
# because any user-specific rules anywhere on the path
# will take precedence over any group rules.
return pr[USER_PATH_RULE][user]
if remaining_groups:
# Now we need to see if any of the group rules here are for
# the rules that the user is a member of. We only need to do
# that test if there are any user groups remaining, which have
# not occured in rules anywhere along the path so far. Even
# if we are done with all rules already, we have to continue
# the loop, because maybe there still is a user rule somewhere
# higher up the path, which will take precedence.
groups = pr[GROUP_NAME_SET]
group_matches = remaining_groups.intersection(groups)
# The permissions for each group that matches need to be accumulated.
for group in group_matches:
group_permissions |= pr[GROUP_PATH_RULE][group]
# Since we only count the match on the longest path for each group,
# we can remove all the groups for which we just had matches from
# any checks further up the path.
remaining_groups.difference_update(group_matches)
return group_permissions
def check_auth(environ, username, password):
"""
Authenticate the user.
As a side effect, the name of the identified user (or
None in case of an anonymous request) will be added to
the environ dictionary with the key 'snap_username', as
well as the groups of the user with the key 'snap_group'.
Since a user can be member of multiple groups, they are
given as a list.
This function is used as WSGI middleware.
@param environ: WSGI environment dictionary.
@type environ: dict
@param username: The name of the user.
@type username: string
@param password: The password of the user.
@type password: string
@return: True if authenticated and authorized successfully.
@rtype: bool
"""
global __user_lookup
# We need the unquoted path for comparison to our rule table. The
# same unquoted path is also used in other parts of our system
# later on. That's why we just add the unquoted path as an element
# to the WSGI request object. Our subsequent layers can just use it
# then, rather than having to re-do the unquoting.
uri = unquote(environ['PATH_INFO'])
environ['SNAP_UNQ_PATH'] = uri
# If this request came from the CC or from the server itself, then
# there should be a token identifying that in the header. Retrieve that
# and confirm that the tokens are valid. If they are valid, then also
# check the header for invoker username. CC and Server requests have the
# privilege of being su-ed to the username specified in the HTTP invoker
# header.
s_token = environ.get(headers.WSGI_SERVER_TOKEN_HEADER)
cc_token = environ.get(headers.WSGI_CC_TOKEN_HEADER)
if s_token is not None:
if unquote(s_token) == server.server_token:
invoker_username = environ.get(headers.WSGI_INVOKER_HEADER)
else:
return False
else:
if cc_token is not None:
if unquote(cc_token) in server.cc_token_list:
invoker_username = environ.get(headers.WSGI_INVOKER_HEADER)
else:
return False
else:
invoker_username = None
if invoker_username is not None:
# We su the request to the username specified in the invoker header.
username = invoker_username
password = None
if not username:
# Handle anonymous requests. In that case, the username is None,
# and we indicate membership in the group 'public', since everyone
# is a member of that group.
groups = ['public']
else:
# Some authentication was provided, so we perform our necessary
# checks...
try:
# Password cache or not, we first want to ensure that this user
# currently is in our user list.
ue = __user_lookup[username]
# The md5crypt() function is very CPU intensive. Therefore, we
# first consult our cache.
# We only do password check if no valid server token or cc token is provided.
if s_token is None and cc_token is None:
if (username in __password_cache and __password_cache[username] == password) or \
md5crypt(password, ue.password) == ue.password:
# Even if the cache entry used to be incorrect, we are now updating
# it in any case.
__password_cache[username] = password
else:
# If the password was wrong.
return False
# We successfully authenticated this user. Everyone is in the group
# 'public', but once authenticated they are also in 'known'.
groups = ue.groups + [ 'public', 'known' ]
except Exception, e:
# We end up here if the user doesn't exist.
return False
environ['snap_username'] = username
environ['snap_groups'] = groups
# Check the path, user and groups to get the permission set.
p = check_uri(uri, username, groups)
if p == 0:
# No permissions at all? We can abort this request right here.
return False
# We attach the permission set to the WSGI request object, so that other
# parts of the system can see it as well.
environ['snap_permissions'] = p
return True
class SnapAuthBasicHandler(AuthBasicHandler):
"""
We override the __call__ function of the AuthBasicHandler
that is provided by Paste. The reason we do that is because
Paste's auth-handler will reject any attempt for anonymous
browsing, not giving us a chance to even see the request.
So, what we do here is that we take the __call__ function
and provide our own implementation as a wrapper around the
actual __call__ function of this class. That way, we can
check whether any username/password has been provided in
the request, and do some handling on that.
"""
def __init__(self, application, realm, authfunc):
"""
Initialization of our auth-handler.
Normally, the authfunc is not stored. But to implement our
special shortcut, we need to store a copy of it.
"""
super(SnapAuthBasicHandler, self).__init__(application, realm, authfunc)
self.authfunc = authfunc
def __call__(self, environ, start_response):
"""
Callable for the authenticator.
We implement a special shortcut: If no username of password
is provideded (anonymous request), we call our auth() function
anyway, rather than just fail (as the original __call__ function
would force it to do).
"""
if not AUTHORIZATION(environ) and not REMOTE_USER(environ):
# No authentication information was provided. We will
# pass this request on to our own auth function, but with
# username and password set to None.
if self.authfunc(environ, None, None):
return self.application(environ, start_response)
else:
global rlog
# Request couldn't be authenticated. In order to log it with our
# specialized request logger we need to create an http_req object
# for it first.
http_req = HttpRequest(environ, None)
rlog(http_req, 401)
# Some auth function exists (either in the request or the environment)?
# Then it's business as usual.
return super(SnapAuthBasicHandler, self).__call__(environ, start_response)
|