# MetadataSecurityOps.java

    /*
    ** Module   : MetadataSecurityOps.java
    ** Abstract : Progress 4GL compatible security interface
    **
    ** Copyright (c) 2013-2023, Golden Code Development Corporation.
    **
    ** -#- -I- --Date-- ----------------------------------Description---------------------------------
    ** 001 OM  20130913 First version, declaring the basic four methods: get/set with and without the
    **                  logical database name.
    ** 002 EVL 20160224 Javadoc fixes to make compatible with Oracle Java 8 for Solaris 10.
    ** 003 OM  20180219 Fixed getUserIdFromDB() for databases aliases.
    ** 004 OM  20180305 Dropped [defaultDatabase] notion.
    ** 005 CA  20181128 Allow assigning a USERID for a specified database without authentication.
    **                  Used by cases when SET-DB-CLIENT provides a SSO CLIENT-PRINCIPAL.
    ** 006 IAS 20191017 Added "DOMAIN-NAME" metadata field definition
    ** 007 IAS 20220713 Added 'lock' argument to the setUserIdDirect method
    **     ECF 20220526 Renamed _User metadata table fields to include leading underscore.
    **     CA  20230607 The leading underscore for _User metadata table fields depends on the minimal conversion
    **                  mode, so rely on this namespace attribute when resolving the field names.
    */

    /*
    ** This program is free software: you can redistribute it and/or modify
    ** it under the terms of the GNU Affero General Public License as
    ** published by the Free Software Foundation, either version 3 of the
    ** License, or (at your option) any later version.
    **
    ** This program is distributed in the hope that it will be useful,
    ** but WITHOUT ANY WARRANTY; without even the implied warranty of
    ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    ** GNU Affero General Public License for more details.
    **
    ** You may find a copy of the GNU Affero GPL version 3 at the following
    ** location: https://www.gnu.org/licenses/agpl-3.0.en.html
    **
    ** Additional terms under GNU Affero GPL version 3 section 7:
    **
    **   Under Section 7 of the GNU Affero GPL version 3, the following additional
    **   terms apply to the works covered under the License.  These additional terms
    **   are non-permissive additional terms allowed under Section 7 of the GNU
    **   Affero GPL version 3 and may not be removed by you.
    **
    **   0. Attribution Requirement.
    **
    **     You must preserve all legal notices or author attributions in the covered
    **     work or Appropriate Legal Notices displayed by works containing the covered
    **     work.  You may not remove from the covered work any author or developer
    **     credit already included within the covered work.
    **
    **   1. No License To Use Trademarks.
    **
    **     This license does not grant any license or rights to use the trademarks
    **     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
    **     of Golden Code Development Corporation. You are not authorized to use the
    **     name Golden Code, FWD, or the names of any author or contributor, for
    **     publicity purposes without written authorization.
    **
    **   2. No Misrepresentation of Affiliation.
    **
    **     You may not represent yourself as Golden Code Development Corporation or FWD.
    **
    **     You may not represent yourself for publicity purposes as associated with
    **     Golden Code Development Corporation, FWD, or any author or contributor to
    **     the covered work, without written authorization.
    **
    **   3. No Misrepresentation of Source or Origin.
    **
    **     You may not represent the covered work as solely your work.  All modified
    **     versions of the covered work must be marked in a reasonable way to make it
    **     clear that the modified work is not originating from Golden Code Development
    **     Corporation or FWD.  All modified versions must contain the notices of
    **     attribution required in this license.
    */

    package com.goldencode.p2j.util;

    import java.util.*;
    import java.util.concurrent.*;

    import com.goldencode.p2j.cfg.*;
    import com.goldencode.p2j.convert.*;
    import com.goldencode.p2j.persist.*;

    /**
     * Implementation of user security functions of P4GL using the _User metadata from database.
     *
     * From my tests, Progress does not use locks when executing <code>setuserid</code> and
     * <code>userid</code> functions. If a user is exclusive-locking the _user table, another one
     * can get/set userid for the same database. However, attempting to execute a FIND query on the
     * _user meta-table it will be put on hold until the first user releases the lock.
     *
     * TODO:
     * Err 709: "-P and -U startup parameters require _User file records."
     */
    public class MetadataSecurityOps
    implements CustomSecurityOps
    {
       /**
        * Constant used for accessing the converted _User meta-table.
        */
       public static final String META_TABLE_USER =  "meta_user";

       /** The user ID field name, per ldb. */
       private static Map<String, String> metaFieldUserId = new ConcurrentHashMap<>();

       /** The domain field name, per ldb. */
       private static Map<String, String> metaFieldDomain = new ConcurrentHashMap<>();

       /** The password field name, per ldb. */
       private static Map<String, String> metaFieldPassword = new ConcurrentHashMap<>();

       /**
        * Compute the name of the user ID field, depending if the conversion for this ldb is minimal or not.
        *
        * @return   The field name.
        */
       public static String getMetaFieldUserid(String ldbName)
       {
          // TODO:  this code is to be removed when we implement all metadata tables to use the underscore prefix
          // instead of the meta_ prefix, and the meta fields will use the underscore always.
          String fieldName = metaFieldUserId.computeIfAbsent(ldbName.toLowerCase(), (k) ->
          {
             int cvt = MatchPhraseConstants.SQL_MODE_DEFAULT;
             try
             {
                cvt = Configuration.getSchemaConfig().getSqlConversion(ldbName);
             }
             catch (Exception e)
             {
                // ignore
             }

             return cvt == MatchPhraseConstants.SQL_MODE_DEFAULT ? "userid" : "_userid";
          });

          return fieldName;
       }

       /**
        * Compute the name of the domain field, depending if the conversion for this ldb is minimal or not.
        *
        * @return   The field name.
        */
       public static String getMetaFieldDomain(String ldbName)
       {
          // TODO:  this code is to be removed when we implement all metadata tables to use the underscore prefix
          // instead of the meta_ prefix, and the meta fields will use the underscore always.
          String fieldName = metaFieldDomain.computeIfAbsent(ldbName.toLowerCase(), (k) ->
          {
             int cvt = MatchPhraseConstants.SQL_MODE_DEFAULT;
             try
             {
                cvt = Configuration.getSchemaConfig().getSqlConversion(ldbName);
             }
             catch (Exception e)
             {
                // ignore
             }

             return cvt == MatchPhraseConstants.SQL_MODE_DEFAULT ? "domain_name" : "_domain_name";
          });

          return fieldName;
       }

       /**
        * Compute the name of the password field, depending if the conversion for this ldb is minimal or not.
        *
        * @return   The field name.
        */
       public static String getMetaFieldPassword(String ldbName)
       {
          // TODO:  this code is to be removed when we implement all metadata tables to use the underscore prefix
          // instead of the meta_ prefix, and the meta fields will use the underscore always.
          String fieldName = metaFieldPassword.computeIfAbsent(ldbName.toLowerCase(), (k) ->
          {
             int cvt = MatchPhraseConstants.SQL_MODE_DEFAULT;
             try
             {
                cvt = Configuration.getSchemaConfig().getSqlConversion(ldbName);
             }
             catch (Exception e)
             {
                // ignore
             }

             return cvt == MatchPhraseConstants.SQL_MODE_DEFAULT ? "password" : "_password";
          });

          return fieldName;
       }

       /**
        * Returns the userid associated with the current connected database. If no database is
        * connected or there are at least two databases connected empty string is returned.
        *
        * @return   The current user context's userid or the empty string in the case of any problem.
        */
       @Override
       public String getUserId()
       {
          // this 4GL functions tends (?) to return the user id of the "default" database
          // the "default" database is the one connected at compile time
          return getUserIdFromDB(ConnectionManager.getDefaultDatabase());
       }

       /**
        * Returns the userid associated with the current user of the given logical database.
        *
        * @param    dbname
        *           Logical database name.
        *
        * @return  The current user context's userid for specified database. If database is not known
        *          or not connected, the empty string is returned. If an {@code unknown} parameter is
        *          provided the method returns {@code unknown} value.
        */
       @Override
       public String getUserIdFromDB(String dbname)
       {
          // null/empty validation
          if (dbname == null)
          {
             return null; // will be wrapped as [unknown] value
          }

          // TODO: if the database is known but not connected, display/throw error:
          // 1072: "SETUSERID of database <db-name> requires that database to be connected".

          // if the database name is not known, blank user is returned:
          character ldbName = ConnectionManager.ldbName(dbname);
          if (ldbName.isUnknown())
          {
             // the 'dbname' is not a valid connected database logical name nor an alias
             // at runtime, we get an empty string (not unknown, not error)
             return SecurityOps.BLANK_USER;
          }

          // use the cached user id from ConnectionManager
          return ConnectionManager.getCurrentUserid(ldbName.toStringMessage());
       }

       /**
        * Authenticates the user for specified DB connection. Checks if the user login account match
        * the corresponding record of the _User table of the database. If there are multiple database
        * connections or none is connected, an error is issued.
        *
        * @param    userid
        *           The name of the user to set as UserID.
        * @param    password
        *           The users password.
        *
        * @return   The <code>true</code> in case of valid user match <code>false</code> otherwise.
        */
       @Override
       public boolean setUserId(String userid, String password)
       {
          return setUserId(userid, password, ConnectionManager.getDefaultDatabase());
       }

       /**
        * Authenticates the user for specified DB connection. Checks if the user login account match
        * the corresponding record of the _User table of the database.  If the specified database
        * is not connected, an error is issued.
        *
        * The user/password comparing operation is done as 4GL:
        * <ul>
        *    <li><strong>user</strong>: is case-insensitive, trailing spaces are ignored
        *             (in upper(rtrim()) mode)
        *    <li><strong>password</strong>: must be an exact match: after applying <code>ENCODE</code>
        *             to password string it is compared to existing record from database in case
        *             sensitive, taking into consideration trailing spaces
        * </ul>
        *
        * @param    userid
        *           The name of the user to set as UserID.
        * @param    password
        *           The users password.
        * @param    dbname
        *           Logical database name.
        *
        * @return   The <code>true</code> in case of valid user match <code>false</code> otherwise.
        */
       @Override
       public boolean setUserId(String userid, String password, String dbname)
       {
          if (ConnectionManager.authenticate(userid, password, dbname))
          {
             ConnectionManager.setAuthenticatedUserid(dbname, userid, true, false);
             return true;
          }

          // authentication failed
          return false;
       }

       /**
        * Sets the specified user ID directly, without authentication, for the given database.
        *
        * @param    userid
        *           The name of the user to set as UserID.
        * @param    dbname
        *           Logical database name.
        * @param    lock
        *           Flag  indicating that the database should be locked
        */
       @Override
       public void setUserIdDirect(String userid, String dbname, boolean lock)
       {
          ConnectionManager.setAuthenticatedUserid(dbname, userid, lock, false);
       }

       /**
        * Check the access level for a database. This will query the _User table and return the value.
        *
        * @param   ldbname
        *          The logical database name to query.
        *
        * @return  The access level to specified database.
        */
       @Override
       public int getAuthLevel(String ldbname)
       {
          return ConnectionManager.getAuthLevel(ldbname);
       }

       /**
        * Checks if the currently authenticated user has access to a database.
        * <p>
        * If the database does not have any users defined in _user meta table the authentication is
        * not enforced so any user can access it. Otherwise, the user must have entered a correct
        * combination of userid / password to get access to respective database.
        *
        * @param   ldbName
        *          The logical name of the database.
        *
        * @return  <code>true</code> if access to requested database is granted
        */
       @Override
       public boolean hasAccessToDatabase(String ldbName)
       {
          if (getAuthLevel(ldbName) != SecurityOps.STRICT)
          {
             return true;
          }

          final String currentUserid = ConnectionManager.getCurrentUserid(ldbName);
          // returning true if currentUserid is not null and not empty (blank)
          return currentUserid != null && !SecurityOps.BLANK_USER.equals(currentUserid);
       }
    }