/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 */
package org.apache.cassandra.auth;

import java.util.*;

import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.cassandra.config.Schema;
import org.apache.cassandra.cql3.UntypedResultSet;
import org.apache.cassandra.cql3.QueryProcessor;
import org.apache.cassandra.cql3.statements.SelectStatement;
import org.apache.cassandra.db.ConsistencyLevel;
import org.apache.cassandra.db.marshal.UTF8Type;
import org.apache.cassandra.exceptions.*;
import org.apache.cassandra.service.ClientState;
import org.apache.cassandra.service.QueryState;
import org.apache.cassandra.transport.messages.ResultMessage;
import org.apache.cassandra.utils.ByteBufferUtil;

/**
 * CassandraAuthorizer is an IAuthorizer implementation that keeps
 * permissions internally in C* - in system_auth.permissions CQL3 table.
 */
public class CassandraAuthorizer implements IAuthorizer
{
    private static final Logger logger = LoggerFactory.getLogger(CassandraAuthorizer.class);

    private static final String USERNAME = "username";
    private static final String RESOURCE = "resource";
    private static final String PERMISSIONS = "permissions";

    private static final String PERMISSIONS_CF = "permissions";
    private static final String PERMISSIONS_CF_SCHEMA = String.format("CREATE TABLE %s.%s ("
                                                                      + "username text,"
                                                                      + "resource text,"
                                                                      + "permissions set<text>,"
                                                                      + "PRIMARY KEY(username, resource)"
                                                                      + ") WITH gc_grace_seconds=%d",
                                                                      Auth.AUTH_KS,
                                                                      PERMISSIONS_CF,
                                                                      90 * 24 * 60 * 60); // 3 months.

    private SelectStatement authorizeStatement;

    // Returns every permission on the resource granted to the user.
    public Set<Permission> authorize(AuthenticatedUser user, IResource resource)
    {
        if (user.isSuper())
            return Permission.ALL;

        UntypedResultSet result;
        try
        {
            ResultMessage.Rows rows = authorizeStatement.execute(ConsistencyLevel.ONE,
                                                                 new QueryState(new ClientState(true)),
                                                                 Lists.newArrayList(ByteBufferUtil.bytes(user.getName()),
                                                                                    ByteBufferUtil.bytes(resource.getName())));
            result = new UntypedResultSet(rows.result);
        }
        catch (RequestValidationException e)
        {
            throw new AssertionError(e); // not supposed to happen
        }
        catch (RequestExecutionException e)
        {
            logger.warn("CassandraAuthorizer failed to authorize {} for {}", user, resource);
            return Permission.NONE;
        }

        if (result.isEmpty() || !result.one().has(PERMISSIONS))
            return Permission.NONE;

        Set<Permission> permissions = EnumSet.noneOf(Permission.class);
        for (String perm : result.one().getSet(PERMISSIONS, UTF8Type.instance))
            permissions.add(Permission.valueOf(perm));
        return permissions;
    }

    public void grant(AuthenticatedUser performer, Set<Permission> permissions, IResource resource, String to)
    throws RequestExecutionException
    {
        modify(permissions, resource, to, "+");
    }

    public void revoke(AuthenticatedUser performer, Set<Permission> permissions, IResource resource, String from)
    throws RequestExecutionException
    {
        modify(permissions, resource, from, "-");
    }

    // Adds or removes permissions from user's 'permissions' set (adds if op is "+", removes if op is "-")
    private void modify(Set<Permission> permissions, IResource resource, String user, String op) throws RequestExecutionException
    {
        process(String.format("UPDATE %s.%s SET permissions = permissions %s {%s} WHERE username = '%s' AND resource = '%s'",
                              Auth.AUTH_KS,
                              PERMISSIONS_CF,
                              op,
                              "'" + StringUtils.join(permissions, "','") + "'",
                              escape(user),
                              escape(resource.getName())));
    }

    // 'of' can be null - in that case everyone's permissions have been requested. Otherwise only single user's.
    // If the user requesting 'LIST PERMISSIONS' is not a superuser OR his username doesn't match 'of', we
    // throw UnauthorizedException. So only a superuser can view everybody's permissions. Regular users are only
    // allowed to see their own permissions.
    public Set<PermissionDetails> list(AuthenticatedUser performer, Set<Permission> permissions, IResource resource, String of)
    throws RequestValidationException, RequestExecutionException
    {
        if (!performer.isSuper() && !performer.getName().equals(of))
            throw new UnauthorizedException(String.format("You are not authorized to view %s's permissions",
                                                          of == null ? "everyone" : of));

        Set<PermissionDetails> details = new HashSet<PermissionDetails>();

        for (UntypedResultSet.Row row : process(buildListQuery(resource, of)))
        {
            if (row.has(PERMISSIONS))
            {
                for (String p : row.getSet(PERMISSIONS, UTF8Type.instance))
                {
                    Permission permission = Permission.valueOf(p);
                    if (permissions.contains(permission))
                        details.add(new PermissionDetails(row.getString(USERNAME),
                                                          DataResource.fromName(row.getString(RESOURCE)),
                                                          permission));
                }
            }
        }

        return details;
    }

    private static String buildListQuery(IResource resource, String of)
    {
        List<String> vars = Lists.newArrayList(Auth.AUTH_KS, PERMISSIONS_CF);
        List<String> conditions = new ArrayList<String>();

        if (resource != null)
        {
            conditions.add("resource = '%s'");
            vars.add(escape(resource.getName()));
        }

        if (of != null)
        {
            conditions.add("username = '%s'");
            vars.add(escape(of));
        }

        String query = "SELECT username, resource, permissions FROM %s.%s";

        if (!conditions.isEmpty())
            query += " WHERE " + StringUtils.join(conditions, " AND ");

        if (resource != null && of == null)
            query += " ALLOW FILTERING";

        return String.format(query, vars.toArray());
    }

    // Called prior to deleting the user with DROP USER query. Internal hook, so no permission checks are needed here.
    public void revokeAll(String droppedUser)
    {
        try
        {
            process(String.format("DELETE FROM %s.%s WHERE username = '%s'", Auth.AUTH_KS, PERMISSIONS_CF, escape(droppedUser)));
        }
        catch (Throwable e)
        {
            logger.warn("CassandraAuthorizer failed to revoke all permissions of {}: {}", droppedUser, e);
        }
    }

    // Called after a resource is removed (DROP KEYSPACE, DROP TABLE, etc.).
    public void revokeAll(IResource droppedResource)
    {

        UntypedResultSet rows;
        try
        {
            // TODO: switch to secondary index on 'resource' once https://issues.apache.org/jira/browse/CASSANDRA-5125 is resolved.
            rows = process(String.format("SELECT username FROM %s.%s WHERE resource = '%s' ALLOW FILTERING",
                                         Auth.AUTH_KS,
                                         PERMISSIONS_CF,
                                         escape(droppedResource.getName())));
        }
        catch (Throwable e)
        {
            logger.warn("CassandraAuthorizer failed to revoke all permissions on {}: {}", droppedResource, e);
            return;
        }

        for (UntypedResultSet.Row row : rows)
        {
            try
            {
                process(String.format("DELETE FROM %s.%s WHERE username = '%s' AND resource = '%s'",
                                      Auth.AUTH_KS,
                                      PERMISSIONS_CF,
                                      escape(row.getString(USERNAME)),
                                      escape(droppedResource.getName())));
            }
            catch (Throwable e)
            {
                logger.warn("CassandraAuthorizer failed to revoke all permissions on {}: {}", droppedResource, e);
            }
        }
    }

    public Set<DataResource> protectedResources()
    {
        return ImmutableSet.of(DataResource.columnFamily(Auth.AUTH_KS, PERMISSIONS_CF));
    }

    public void validateConfiguration() throws ConfigurationException
    {
    }

    public void setup()
    {
        if (Schema.instance.getCFMetaData(Auth.AUTH_KS, PERMISSIONS_CF) == null)
        {
            try
            {
                process(PERMISSIONS_CF_SCHEMA);
            }
            catch (RequestExecutionException e)
            {
                throw new AssertionError(e);
            }
        }

        try
        {
            String query = String.format("SELECT permissions FROM %s.%s WHERE username = ? AND resource = ?", Auth.AUTH_KS, PERMISSIONS_CF);
            authorizeStatement = (SelectStatement) QueryProcessor.parseStatement(query).prepare().statement;
        }
        catch (RequestValidationException e)
        {
            throw new AssertionError(e); // not supposed to happen
        }
    }

    // We only worry about one character ('). Make sure it's properly escaped.
    private static String escape(String name)
    {
        return StringUtils.replace(name, "'", "''");
    }

    private static UntypedResultSet process(String query) throws RequestExecutionException
    {
        return QueryProcessor.process(query, ConsistencyLevel.ONE);
    }
}
