// Tests rollback of auth data in replica sets.
// This test creates a user and then does two different sets of updates to that user's privileges
// using the replSetTest command to trigger a rollback and verify that at the end the access control
// data is rolled back correctly and the user only has access to the expected collections.
//
// If all data-bearing nodes in a replica set are using an ephemeral storage engine, the set will
// not be able to survive a scenario where all data-bearing nodes are down simultaneously. In such a
// scenario, none of the members will have any data, and upon restart will each look for a member to
// inital sync from, so no primary will be elected. This test induces such a scenario, so cannot be
// run on ephemeral storage engines.
// @tags: [requires_persistence]

import {ReplSetTest} from "jstests/libs/replsettest.js";

// Multiple users cannot be authenticated on one connection within a session.
TestData.disableImplicitSessions = true;

// helper function for verifying contents at the end of the test
function checkFinalResults(db) {
    assert.commandWorked(db.runCommand({dbStats: 1}));
    assert.commandFailedWithCode(db.runCommand({collStats: 'foo'}), authzErrorCode);
    assert.commandFailedWithCode(db.runCommand({collStats: 'bar'}), authzErrorCode);
    assert.commandWorked(db.runCommand({collStats: 'baz'}));
    assert.commandWorked(db.runCommand({collStats: 'foobar'}));
}

const authzErrorCode = 13;

jsTestLog("Setting up replica set");

const name = "rollbackAuth";
const replTest = new ReplSetTest({name: name, nodes: 3, keyFile: 'jstests/libs/key1'});
const nodes = replTest.nodeList();
const conns = replTest.startSet();
replTest.initiate({
    "_id": "rollbackAuth",
    "members": [
        {"_id": 0, "host": nodes[0], "priority": 3},
        {"_id": 1, "host": nodes[1]},
        {"_id": 2, "host": nodes[2], arbiterOnly: true}
    ]
},
                  null,
                  {initiateWithDefaultElectionTimeout: true});

// Make sure we have a primary
replTest.waitForState(replTest.nodes[0], ReplSetTest.State.PRIMARY);
const primary = replTest.getPrimary();
const a_conn = conns[0];
const b_conn = conns[1];
a_conn.setSecondaryOk();
b_conn.setSecondaryOk();
const A_admin = a_conn.getDB("admin");
const B_admin = b_conn.getDB("admin");
const A_test = a_conn.getDB("test");
const B_test = b_conn.getDB("test");
assert.eq(primary, conns[0], "conns[0] assumed to be primary");
assert.eq(a_conn, primary);

// Make sure we have an arbiter
assert.soon(function() {
    const res = conns[2].getDB("admin").runCommand({replSetGetStatus: 1});
    return res.myState == 7;
}, "Arbiter failed to initialize.");

jsTestLog("Creating initial data");

// Create collections that will be used in test
A_admin.createUser({user: 'admin', pwd: 'pwd', roles: ['root']});
A_admin.auth('admin', 'pwd');

// Set up user admin user
A_admin.createUser({user: 'userAdmin', pwd: 'pwd', roles: ['userAdminAnyDatabase']});

A_test.foo.insert({a: 1});
A_test.bar.insert({a: 1});
A_test.baz.insert({a: 1});
A_test.foobar.insert({a: 1});
A_admin.logout();

assert(A_admin.auth('userAdmin', 'pwd'));

// Give replication time to catch up.
assert.soon(function() {
    return B_admin.auth('userAdmin', 'pwd');
});

// Create a basic user and role
A_admin.createRole({
    role: 'replStatusRole',  // To make awaitReplication() work
    roles: [],
    privileges: [
        {resource: {cluster: true}, actions: ['replSetGetStatus']},
        {resource: {db: 'local', collection: ''}, actions: ['find']},
        {resource: {db: 'local', collection: 'system.replset'}, actions: ['find']}
    ]
});
A_test.createRole({
    role: 'myRole',
    roles: [],
    privileges: [{resource: {db: 'test', collection: ''}, actions: ['dbStats']}]
});
A_test.createUser(
    {user: 'spencer', pwd: 'pwd', roles: ['myRole', {role: 'replStatusRole', db: 'admin'}]});

A_admin.logout();
B_admin.logout();

assert(A_test.auth('spencer', 'pwd'));

// wait for secondary to get this data
assert.soon(function() {
    return B_test.auth('spencer', 'pwd');
});

assert.commandWorked(A_test.runCommand({dbStats: 1}));
assert.commandFailedWithCode(A_test.runCommand({collStats: 'foo'}), authzErrorCode);
assert.commandFailedWithCode(A_test.runCommand({collStats: 'bar'}), authzErrorCode);
assert.commandFailedWithCode(A_test.runCommand({collStats: 'baz'}), authzErrorCode);
assert.commandFailedWithCode(A_test.runCommand({collStats: 'foobar'}), authzErrorCode);

assert.commandWorked(B_test.runCommand({dbStats: 1}));
assert.commandFailedWithCode(B_test.runCommand({collStats: 'foo'}), authzErrorCode);
assert.commandFailedWithCode(B_test.runCommand({collStats: 'bar'}), authzErrorCode);
assert.commandFailedWithCode(B_test.runCommand({collStats: 'baz'}), authzErrorCode);
assert.commandFailedWithCode(B_test.runCommand({collStats: 'foobar'}), authzErrorCode);

replTest.awaitLastOpCommitted();

jsTestLog("Doing writes that will eventually be rolled back");

// down A and wait for B to become primary
A_test.logout();
replTest.stop(0);
assert.soon(function() {
    try {
        return B_admin.hello().isWritablePrimary;
    } catch (e) {
        return false;
    }
}, "B didn't become primary");
printjson(assert.commandWorked(B_test.adminCommand('replSetGetStatus')));
B_test.logout();

// Modify the the user and role in a way that will be rolled back.
assert(B_admin.auth('admin', 'pwd'));
B_test.grantPrivilegesToRole(
    'myRole',
    [{resource: {db: 'test', collection: 'foo'}, actions: ['collStats']}],
    {});  // Default write concern will wait for majority, which will time out.
B_test.createRole({
    role: 'temporaryRole',
    roles: [],
    privileges: [{resource: {db: 'test', collection: 'bar'}, actions: ['collStats']}]
},
                  {});  // Default write concern will wait for majority, which will time out.
B_test.grantRolesToUser('spencer',
                        ['temporaryRole'],
                        {});  // Default write concern will wait for majority, which will time out.
B_admin.logout();

assert(B_test.auth('spencer', 'pwd'));
assert.commandWorked(B_test.runCommand({dbStats: 1}));
assert.commandWorked(B_test.runCommand({collStats: 'foo'}));
assert.commandWorked(B_test.runCommand({collStats: 'bar'}));
assert.commandFailedWithCode(B_test.runCommand({collStats: 'baz'}), authzErrorCode);
assert.commandFailedWithCode(B_test.runCommand({collStats: 'foobar'}), authzErrorCode);
B_test.logout();

// down B, bring A back up, then wait for A to become primary
// insert new data into A so that B will need to rollback when it reconnects to A
replTest.stop(1);

replTest.restart(0);
assert.soon(function() {
    try {
        const helloResponse = A_admin.hello();
        jsTestLog("A hello response: " + tojson(helloResponse));
        return helloResponse.isWritablePrimary;
    } catch (e) {
        jsTestLog("hello() threw an exception when waiting for A to become primary: " + tojson(e));
        return false;
    }
}, "A didn't become primary");

// A should not have the new data as it was down
assert(A_test.auth('spencer', 'pwd'));
assert.commandWorked(A_test.runCommand({dbStats: 1}));
assert.commandFailedWithCode(A_test.runCommand({collStats: 'foo'}), authzErrorCode);
assert.commandFailedWithCode(A_test.runCommand({collStats: 'bar'}), authzErrorCode);
assert.commandFailedWithCode(A_test.runCommand({collStats: 'baz'}), authzErrorCode);
assert.commandFailedWithCode(A_test.runCommand({collStats: 'foobar'}), authzErrorCode);
A_test.logout();

jsTestLog("Doing writes that should persist after the rollback");
// Modify the user and role in a way that will persist.
A_admin.auth('userAdmin', 'pwd');
// Default write concern will wait for majority, which would time out
// so we override it with an empty write concern
A_test.grantPrivilegesToRole(
    'myRole', [{resource: {db: 'test', collection: 'baz'}, actions: ['collStats']}], {});

A_test.createRole({
    role: 'persistentRole',
    roles: [],
    privileges: [{resource: {db: 'test', collection: 'foobar'}, actions: ['collStats']}]
},
                  {});
A_test.grantRolesToUser('spencer', ['persistentRole'], {});
A_admin.logout();

A_test.auth('spencer', 'pwd');

// A has the data we just wrote, but not what B wrote before
checkFinalResults(A_test);

jsTestLog("Triggering rollback");

// bring B back in contact with A
// as A is primary, B will roll back and then catch up
replTest.restart(1);
assert.soonNoExcept(function() {
    authutil.asCluster(replTest.nodes, 'jstests/libs/key1', function() {
        replTest.awaitReplication();
    });

    return B_test.auth('spencer', 'pwd');
});
// Now both A and B should agree
checkFinalResults(A_test);
checkFinalResults(B_test);

A_test.logout();

// Verify data consistency between nodes.
authutil.asCluster(replTest.nodes, 'jstests/libs/key1', function() {
    replTest.checkOplogs();
});

// DB hash check is done in stopSet.
replTest.stopSet();
