You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by da...@apache.org on 2010/06/23 21:23:54 UTC

svn commit: r957316 - in /couchdb/trunk: share/www/script/test/auth_cache.js src/couchdb/couch_auth_cache.erl src/couchdb/couch_js_functions.hrl

Author: damien
Date: Wed Jun 23 19:23:54 2010
New Revision: 957316

URL: http://svn.apache.org/viewvc?rev=957316&view=rev
Log:
Added files missing from last checkin for COUCHDB-807

Added:
    couchdb/trunk/share/www/script/test/auth_cache.js
    couchdb/trunk/src/couchdb/couch_auth_cache.erl
    couchdb/trunk/src/couchdb/couch_js_functions.hrl

Added: couchdb/trunk/share/www/script/test/auth_cache.js
URL: http://svn.apache.org/viewvc/couchdb/trunk/share/www/script/test/auth_cache.js?rev=957316&view=auto
==============================================================================
--- couchdb/trunk/share/www/script/test/auth_cache.js (added)
+++ couchdb/trunk/share/www/script/test/auth_cache.js Wed Jun 23 19:23:54 2010
@@ -0,0 +1,237 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License.  You may obtain a copy
+// of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+couchTests.auth_cache = function(debug) {
+
+  if (debug) debugger;
+
+  // Simple secret key generator
+  function generateSecret(length) {
+    var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +
+              "0123456789+/";
+    var secret = '';
+    for (var i = 0; i < length; i++) {
+      secret += tab.charAt(Math.floor(Math.random() * 64));
+    }
+    return secret;
+  }
+
+  var authDb = new CouchDB("test_suite_users", {"X-Couch-Full-Commit":"false"});
+  var server_config = [
+    {
+      section: "couch_httpd_auth",
+      key: "authentication_db",
+      value: authDb.name
+    },
+    {
+      section: "couch_httpd_auth",
+      key: "auth_cache_size",
+      value: "3"
+    },
+    {
+      section: "httpd",
+      key: "authentication_handlers",
+      value: "{couch_httpd_auth, default_authentication_handler}"
+    },
+    {
+      section: "couch_httpd_auth",
+      key: "secret",
+      value: generateSecret(64)
+    }
+  ];
+
+
+  function hits() {
+    var hits = CouchDB.requestStats("couchdb", "auth_cache_hits", true);
+    return hits.current || 0;
+  }
+
+
+  function misses() {
+    var misses = CouchDB.requestStats("couchdb", "auth_cache_misses", true);
+    return misses.current || 0;
+  }
+
+
+  function testFun() {
+    var hits_before,
+        misses_before,
+        hits_after,
+        misses_after;
+
+    var fdmanana = CouchDB.prepareUserDoc({
+      name: "fdmanana",
+      roles: ["dev"]
+    }, "qwerty");
+
+    T(authDb.save(fdmanana).ok);
+
+    var chris = CouchDB.prepareUserDoc({
+      name: "chris",
+      roles: ["dev", "mafia", "white_costume"]
+    }, "the_god_father");
+
+    T(authDb.save(chris).ok);
+
+    var joe = CouchDB.prepareUserDoc({
+      name: "joe",
+      roles: ["erlnager"]
+    }, "functional");
+
+    T(authDb.save(joe).ok);
+
+    var johndoe = CouchDB.prepareUserDoc({
+      name: "johndoe",
+      roles: ["user"]
+    }, "123456");
+
+    T(authDb.save(johndoe).ok);
+
+    hits_before = hits();
+    misses_before = misses();
+
+    T(CouchDB.login("fdmanana", "qwerty").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === (misses_before + 1));
+    T(hits_after === hits_before);
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    T(CouchDB.logout().ok);
+    T(CouchDB.login("fdmanana", "qwerty").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === misses_before);
+    T(hits_after === (hits_before + 1));
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    T(CouchDB.logout().ok);
+    T(CouchDB.login("chris", "the_god_father").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === (misses_before + 1));
+    T(hits_after === hits_before);
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    T(CouchDB.logout().ok);
+    T(CouchDB.login("joe", "functional").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === (misses_before + 1));
+    T(hits_after === hits_before);
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    T(CouchDB.logout().ok);
+    T(CouchDB.login("johndoe", "123456").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === (misses_before + 1));
+    T(hits_after === hits_before);
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    T(CouchDB.logout().ok);
+    T(CouchDB.login("joe", "functional").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    // it's an MRU cache, joe was removed from cache to add johndoe
+    T(misses_after === (misses_before + 1));
+    T(hits_after === hits_before);
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    T(CouchDB.logout().ok);
+    T(CouchDB.login("fdmanana", "qwerty").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === misses_before);
+    T(hits_after === (hits_before + 1));
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    var new_salt = CouchDB.newUuids(1)[0];
+    var new_passwd = hex_sha1("foobar" + new_salt);
+    fdmanana.salt = new_salt;
+    fdmanana.password_sha = new_passwd;
+
+    T(authDb.save(fdmanana).ok);
+    T(CouchDB.logout().ok);
+
+    // cache was refreshed
+    T(CouchDB.login("fdmanana", "qwerty").error === "unauthorized");
+    T(CouchDB.login("fdmanana", "foobar").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === misses_before);
+    T(hits_after === (hits_before + 2));
+
+    T(CouchDB.logout().ok);
+
+    hits_before = hits_after;
+    misses_before = misses_after;
+
+    // and yet another update
+    new_salt = CouchDB.newUuids(1)[0];
+    new_passwd = hex_sha1("javascript" + new_salt);
+    fdmanana.salt = new_salt;
+    fdmanana.password_sha = new_passwd;
+
+    T(authDb.save(fdmanana).ok);
+    T(CouchDB.logout().ok);
+
+    // cache was refreshed
+    T(CouchDB.login("fdmanana", "foobar").error === "unauthorized");
+    T(CouchDB.login("fdmanana", "javascript").ok);
+
+    hits_after = hits();
+    misses_after = misses();
+
+    T(misses_after === misses_before);
+    T(hits_after === (hits_before + 2));
+
+    T(CouchDB.logout().ok);
+  }
+
+
+  authDb.deleteDb();
+  run_on_modified_server(server_config, testFun);
+
+  // cleanup
+  authDb.deleteDb();
+}
\ No newline at end of file

Added: couchdb/trunk/src/couchdb/couch_auth_cache.erl
URL: http://svn.apache.org/viewvc/couchdb/trunk/src/couchdb/couch_auth_cache.erl?rev=957316&view=auto
==============================================================================
--- couchdb/trunk/src/couchdb/couch_auth_cache.erl (added)
+++ couchdb/trunk/src/couchdb/couch_auth_cache.erl Wed Jun 23 19:23:54 2010
@@ -0,0 +1,410 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+%   http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_auth_cache).
+-behaviour(gen_server).
+
+% public API
+-export([get_user_creds/1]).
+
+% gen_server API
+-export([start_link/0, init/1, handle_call/3, handle_info/2, handle_cast/2]).
+-export([code_change/3, terminate/2]).
+
+-include("couch_db.hrl").
+-include("couch_js_functions.hrl").
+
+-define(STATE, auth_state_ets).
+-define(BY_USER, auth_by_user_ets).
+-define(BY_ATIME, auth_by_atime_ets).
+
+-record(state, {
+    max_cache_size = 0,
+    cache_size = 0,
+    db_notifier = nil
+}).
+
+
+-spec get_user_creds(UserName::string() | binary()) ->
+    Credentials::list() | nil.
+
+get_user_creds(UserName) when is_list(UserName) ->
+    get_user_creds(?l2b(UserName));
+
+get_user_creds(UserName) ->
+    UserCreds = case couch_config:get("admins", ?b2l(UserName)) of
+    "-hashed-" ++ HashedPwdAndSalt ->
+        % the name is an admin, now check to see if there is a user doc
+        % which has a matching name, salt, and password_sha
+        [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","),
+        case get_from_cache(UserName) of
+        nil ->
+            [{<<"roles">>, [<<"_admin">>]},
+                {<<"salt">>, ?l2b(Salt)},
+                {<<"password_sha">>, ?l2b(HashedPwd)}];
+        UserProps when is_list(UserProps) ->
+            DocRoles = couch_util:get_value(<<"roles">>, UserProps),
+            [{<<"roles">>, [<<"_admin">> | DocRoles]},
+                {<<"salt">>, ?l2b(Salt)},
+                {<<"password_sha">>, ?l2b(HashedPwd)}]
+        end;
+    _Else ->
+        get_from_cache(UserName)
+    end,
+    validate_user_creds(UserCreds).
+
+
+get_from_cache(UserName) ->
+    exec_if_auth_db(
+        fun(_AuthDb) ->
+            maybe_refresh_cache(),
+            case ets:lookup(?BY_USER, UserName) of
+            [] ->
+                gen_server:call(?MODULE, {fetch, UserName}, infinity);
+            [{UserName, {Credentials, _ATime}}] ->
+                couch_stats_collector:increment({couchdb, auth_cache_hits}),
+                gen_server:cast(?MODULE, {cache_hit, UserName}),
+                Credentials
+            end
+        end,
+        nil
+    ).
+
+
+validate_user_creds(nil) ->
+    nil;
+validate_user_creds(UserCreds) ->
+    case couch_util:get_value(<<"_conflicts">>, UserCreds) of
+    undefined ->
+        ok;
+    _ConflictList ->
+        throw({unauthorized,
+            <<"User document conflicts must be resolved before the document",
+              " is used for authentication purposes.">>
+        })
+    end,
+    UserCreds.
+
+
+start_link() ->
+    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+
+init(_) ->
+    ?STATE = ets:new(?STATE, [set, protected, named_table]),
+    ?BY_USER = ets:new(?BY_USER, [set, protected, named_table]),
+    ?BY_ATIME = ets:new(?BY_ATIME, [ordered_set, private, named_table]),
+    AuthDbName = couch_config:get("couch_httpd_auth", "authentication_db"),
+    true = ets:insert(?STATE, {auth_db_name, ?l2b(AuthDbName)}),
+    true = ets:insert(?STATE, {auth_db, open_auth_db()}),
+    process_flag(trap_exit, true),
+    ok = couch_config:register(
+        fun("couch_httpd_auth", "auth_cache_size", SizeList) ->
+            Size = list_to_integer(SizeList),
+            ok = gen_server:call(?MODULE, {new_max_cache_size, Size}, infinity)
+        end
+    ),
+    ok = couch_config:register(
+        fun("couch_httpd_auth", "authentication_db", DbName) ->
+            ok = gen_server:call(?MODULE, {new_auth_db, ?l2b(DbName)}, infinity)
+        end
+    ),
+    {ok, Notifier} = couch_db_update_notifier:start_link(fun handle_db_event/1),
+    State = #state{
+        db_notifier = Notifier,
+        max_cache_size = list_to_integer(
+            couch_config:get("couch_httpd_auth", "auth_cache_size", "50")
+        )
+    },
+    {ok, State}.
+
+
+handle_db_event({Event, DbName}) ->
+    [{auth_db_name, AuthDbName}] = ets:lookup(?STATE, auth_db_name),
+    case DbName =:= AuthDbName of
+    true ->
+        case Event of
+        deleted -> gen_server:call(?MODULE, auth_db_deleted, infinity);
+        created -> gen_server:call(?MODULE, auth_db_created, infinity);
+        _Else   -> ok
+        end;
+    false ->
+        ok
+    end.
+
+
+handle_call({new_auth_db, AuthDbName}, _From, State) ->
+    NewState = clear_cache(State),
+    true = ets:insert(?STATE, {auth_db_name, AuthDbName}),
+    true = ets:insert(?STATE, {auth_db, open_auth_db()}),
+    {reply, ok, NewState};
+
+handle_call(auth_db_deleted, _From, State) ->
+    NewState = clear_cache(State),
+    true = ets:insert(?STATE, {auth_db, nil}),
+    {reply, ok, NewState};
+
+handle_call(auth_db_created, _From, State) ->
+    NewState = clear_cache(State),
+    true = ets:insert(?STATE, {auth_db, open_auth_db()}),
+    {reply, ok, NewState};
+
+handle_call({new_max_cache_size, NewSize}, _From, State) ->
+    case NewSize >= State#state.cache_size of
+    true ->
+        ok;
+    false ->
+        lists:foreach(
+            fun(_) ->
+                LruTime = ets:last(?BY_ATIME),
+                [{LruTime, UserName}] = ets:lookup(?BY_ATIME, LruTime),
+                true = ets:delete(?BY_ATIME, LruTime),
+                true = ets:delete(?BY_USER, UserName)
+            end,
+            lists:seq(1, State#state.cache_size - NewSize)
+        )
+    end,
+    NewState = State#state{
+        max_cache_size = NewSize,
+        cache_size = erlang:min(NewSize, State#state.cache_size)
+    },
+    {reply, ok, NewState};
+
+handle_call({fetch, UserName}, _From, State) ->
+    {Credentials, NewState} = case ets:lookup(?BY_USER, UserName) of
+    [{UserName, {Creds, ATime}}] ->
+        couch_stats_collector:increment({couchdb, auth_cache_hits}),
+        cache_hit(UserName, Creds, ATime),
+        {Creds, State};
+    [] ->
+        couch_stats_collector:increment({couchdb, auth_cache_misses}),
+        Creds = get_user_props_from_db(UserName),
+        State1 = add_cache_entry(UserName, Creds, erlang:now(), State),
+        {Creds, State1}
+    end,
+    {reply, Credentials, NewState};
+
+handle_call(refresh, _From, State) ->
+    exec_if_auth_db(fun refresh_entries/1),
+    {reply, ok, State}.
+
+
+handle_cast({cache_hit, UserName}, State) ->
+    case ets:lookup(?BY_USER, UserName) of
+    [{UserName, {Credentials, ATime}}] ->
+        cache_hit(UserName, Credentials, ATime);
+    _ ->
+        ok
+    end,
+    {noreply, State}.
+
+
+handle_info(_Msg, State) ->
+    {noreply, State}.
+
+
+terminate(_Reason, #state{db_notifier = Notifier}) ->
+    couch_db_update_notifier:stop(Notifier),
+    exec_if_auth_db(fun(AuthDb) -> catch couch_db:close(AuthDb) end),
+    true = ets:delete(?BY_USER),
+    true = ets:delete(?BY_ATIME),
+    true = ets:delete(?STATE).
+
+
+code_change(_OldVsn, State, _Extra) ->
+    {ok, State}.
+
+
+clear_cache(State) ->
+    exec_if_auth_db(fun(AuthDb) -> catch couch_db:close(AuthDb) end),
+    true = ets:delete_all_objects(?BY_USER),
+    true = ets:delete_all_objects(?BY_ATIME),
+    State#state{cache_size = 0}.
+
+
+add_cache_entry(UserName, Credentials, ATime, State) ->
+    case State#state.cache_size >= State#state.max_cache_size of
+    true ->
+        free_mru_cache_entry();
+    false ->
+        ok
+    end,
+    true = ets:insert(?BY_ATIME, {ATime, UserName}),
+    true = ets:insert(?BY_USER, {UserName, {Credentials, ATime}}),
+    State#state{cache_size = couch_util:get_value(size, ets:info(?BY_USER))}.
+
+
+free_mru_cache_entry() ->
+    case ets:last(?BY_ATIME) of
+    '$end_of_table' ->
+        ok;  % empty cache
+    LruTime ->
+        [{LruTime, UserName}] = ets:lookup(?BY_ATIME, LruTime),
+        true = ets:delete(?BY_ATIME, LruTime),
+        true = ets:delete(?BY_USER, UserName)
+    end.
+
+
+cache_hit(UserName, Credentials, ATime) ->
+    NewATime = erlang:now(),
+    true = ets:delete(?BY_ATIME, ATime),
+    true = ets:insert(?BY_ATIME, {NewATime, UserName}),
+    true = ets:insert(?BY_USER, {UserName, {Credentials, NewATime}}).
+
+
+refresh_entries(AuthDb) ->
+    case reopen_auth_db(AuthDb) of
+    nil ->
+        ok;
+    AuthDb2 ->
+        case AuthDb2#db.update_seq > AuthDb#db.update_seq of
+        true ->
+            {ok, _, _} = couch_db:enum_docs_since(
+                AuthDb2,
+                AuthDb#db.update_seq,
+                fun(DocInfo, _, _) -> refresh_entry(AuthDb2, DocInfo) end,
+                AuthDb#db.update_seq,
+                []
+            ),
+            true = ets:insert(?STATE, {auth_db, AuthDb2});
+        false ->
+            ok
+        end
+    end.
+
+
+refresh_entry(Db, #doc_info{high_seq = DocSeq} = DocInfo) ->
+    case is_user_doc(DocInfo) of
+    {true, UserName} ->
+        case ets:lookup(?BY_USER, UserName) of
+        [] ->
+            ok;
+        [{UserName, {_OldCreds, ATime}}] ->
+            {ok, Doc} = couch_db:open_doc(Db, DocInfo, [conflicts]),
+            NewCreds = user_creds(Doc),
+            true = ets:insert(?BY_USER, {UserName, {NewCreds, ATime}})
+        end;
+    false ->
+        ok
+    end,
+    {ok, DocSeq}.
+
+
+user_creds(#doc{deleted = true}) ->
+    nil;
+user_creds(#doc{} = Doc) ->
+    {Creds} = couch_query_servers:json_doc(Doc),
+    Creds.
+
+
+is_user_doc(#doc_info{id = <<"org.couchdb.user:", UserName/binary>>}) ->
+    {true, UserName};
+is_user_doc(_) ->
+    false.
+
+
+maybe_refresh_cache() ->
+    case cache_needs_refresh() of
+    true ->
+        ok = gen_server:call(?MODULE, refresh, infinity);
+    false ->
+        ok
+    end.
+
+
+cache_needs_refresh() ->
+    exec_if_auth_db(
+        fun(AuthDb) ->
+            case reopen_auth_db(AuthDb) of
+            nil ->
+                false;
+            AuthDb2 ->
+                AuthDb2#db.update_seq > AuthDb#db.update_seq
+            end
+        end,
+        false
+    ).
+
+
+reopen_auth_db(AuthDb) ->
+    case (catch gen_server:call(AuthDb#db.main_pid, get_db, infinity)) of
+    {ok, AuthDb2} ->
+        AuthDb2;
+    _ ->
+        nil
+    end.
+
+
+exec_if_auth_db(Fun) ->
+    exec_if_auth_db(Fun, ok).
+
+exec_if_auth_db(Fun, DefRes) ->
+    case ets:lookup(?STATE, auth_db) of
+    [{auth_db, #db{} = AuthDb}] ->
+        Fun(AuthDb);
+    _ ->
+        DefRes
+    end.
+
+
+open_auth_db() ->
+    [{auth_db_name, DbName}] = ets:lookup(?STATE, auth_db_name),
+    {ok, AuthDb} = ensure_users_db_exists(DbName, [sys_db]),
+    AuthDb.
+
+
+get_user_props_from_db(UserName) ->
+    exec_if_auth_db(
+        fun(AuthDb) ->
+            Db = reopen_auth_db(AuthDb),
+            DocId = <<"org.couchdb.user:", UserName/binary>>,
+            try
+                {ok, Doc} = couch_db:open_doc(Db, DocId, [conflicts]),
+                {DocProps} = couch_query_servers:json_doc(Doc),
+                DocProps
+            catch
+            _:_Error ->
+                nil
+            end
+        end,
+        nil
+    ).
+
+ensure_users_db_exists(DbName, Options) ->
+    Options1 = [{user_ctx, #user_ctx{roles=[<<"_admin">>]}} | Options],
+    case couch_db:open(DbName, Options1) of
+    {ok, Db} ->
+        ensure_auth_ddoc_exists(Db, <<"_design/_auth">>),
+        {ok, Db};
+    _Error ->
+        {ok, Db} = couch_db:create(DbName, Options1),
+        ok = ensure_auth_ddoc_exists(Db, <<"_design/_auth">>),
+        {ok, Db}
+    end.
+
+ensure_auth_ddoc_exists(Db, DDocId) ->
+    try
+        couch_httpd_db:couch_doc_open(Db, DDocId, nil, [])
+    catch
+    _:_Error ->
+        {ok, AuthDesign} = auth_design_doc(DDocId),
+        {ok, _Rev} = couch_db:update_doc(Db, AuthDesign, [])
+    end,
+    ok.
+
+auth_design_doc(DocId) ->
+    DocProps = [
+        {<<"_id">>, DocId},
+        {<<"language">>,<<"javascript">>},
+        {<<"validate_doc_update">>, ?AUTH_DB_DOC_VALIDATE_FUNCTION}
+    ],
+    {ok, couch_doc:from_json_obj({DocProps})}.

Added: couchdb/trunk/src/couchdb/couch_js_functions.hrl
URL: http://svn.apache.org/viewvc/couchdb/trunk/src/couchdb/couch_js_functions.hrl?rev=957316&view=auto
==============================================================================
--- couchdb/trunk/src/couchdb/couch_js_functions.hrl (added)
+++ couchdb/trunk/src/couchdb/couch_js_functions.hrl Wed Jun 23 19:23:54 2010
@@ -0,0 +1,97 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License.  You may obtain a copy of
+% the License at
+%
+%   http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-define(AUTH_DB_DOC_VALIDATE_FUNCTION, <<"
+    function(newDoc, oldDoc, userCtx) {
+        if ((oldDoc || newDoc).type !== 'user') {
+            throw({forbidden : 'doc.type must be user'});
+        } // we only validate user docs for now
+
+        if (newDoc._deleted === true) {
+            // allow deletes by admins and matching users
+            // without checking the other fields
+            if ((userCtx.roles.indexOf('_admin') !== -1) ||
+                (userCtx.name == oldDoc.name)) {
+                return;
+            } else {
+                throw({forbidden: 'Only admins may delete other user docs.'});
+            }
+        }
+
+        if (!newDoc.name) {
+            throw({forbidden: 'doc.name is required'});
+        }
+
+        if (!(newDoc.roles && (typeof newDoc.roles.length !== 'undefined'))) {
+            throw({forbidden: 'doc.roles must be an array'});
+        }
+
+        if (newDoc._id !== ('org.couchdb.user:' + newDoc.name)) {
+            throw({
+                forbidden: 'Doc ID must be of the form org.couchdb.user:name'
+            });
+        }
+
+        if (oldDoc) { // validate all updates
+            if (oldDoc.name !== newDoc.name) {
+                throw({forbidden: 'Usernames can not be changed.'});
+            }
+        }
+
+        if (newDoc.password_sha && !newDoc.salt) {
+            throw({
+                forbidden: 'Users with password_sha must have a salt.' +
+                    'See /_utils/script/couch.js for example code.'
+            });
+        }
+
+        if (userCtx.roles.indexOf('_admin') === -1) {
+            if (oldDoc) { // validate non-admin updates
+                if (userCtx.name !== newDoc.name) {
+                    throw({
+                        forbidden: 'You may only update your own user document.'
+                    });
+                }
+                // validate role updates
+                var oldRoles = oldDoc.roles.sort();
+                var newRoles = newDoc.roles.sort();
+
+                if (oldRoles.length !== newRoles.length) {
+                    throw({forbidden: 'Only _admin may edit roles'});
+                }
+
+                for (var i = 0; i < oldRoles.length; i++) {
+                    if (oldRoles[i] !== newRoles[i]) {
+                        throw({forbidden: 'Only _admin may edit roles'});
+                    }
+                }
+            } else if (newDoc.roles.length > 0) {
+                throw({forbidden: 'Only _admin may set roles'});
+            }
+        }
+
+        // no system roles in users db
+        for (var i = 0; i < newDoc.roles.length; i++) {
+            if (newDoc.roles[i][0] === '_') {
+                throw({
+                    forbidden:
+                    'No system roles (starting with underscore) in users db.'
+                });
+            }
+        }
+
+        // no system names as names
+        if (newDoc.name[0] === '_') {
+            throw({forbidden: 'Username may not start with underscore.'});
+        }
+    }
+">>).