New tool: Login Audit Log GUI (Appian interface)

Certified Lead Developer

Hey all!

I'm sharing the Appian interface I recently built to give better visibility into the system's recent internal Login Audit files (instead of constantly downloading and opening CSV files) - particularly helpful if you're an O&M user and/or diagnosing login issues for any other reason.

This interface relies on the Log Reader plug-in (and also uses the Date and Time Utilites plug-in for a certain transform on timestamps).  That said, I can't say how it'll perform on multi-server environments, since I don't have one to try it on.

The entire interface is self-contained, with the sole exception of a custom rule I wrote that utilizes the internal "User" record to take an entered username and get the system's properly-cased username (since there is some inconsistent case sensitivity around usernames and there's no other way I could come up with to tell what the 'real' version is).  I'll include that expression rule code after the main interface code.

a!localVariables(
  local!relevantDay: today(),
  local!isViewingToday: local!relevantDay = today(),

  local!dayString: if(
    local!isViewingToday,
    "",
    "." & text(local!relevantDay, "yyyy-mm-dd")
  ),
  local!refreshCounter: 0,
  
  /* use this (optionally) to filter out a service account that might get a disproportionate number of hits per day.  if none, set it to a nonsense string. */
  local!usernameToFilterOut: "api.test.account.name.asdf",

  local!currentLog: a!refreshVariable(
    value: readcsvlogpagingwithheaders(
      csvPath: "login-audit.csv" & local!dayString,
      startIndex: 1,
      batchSize: 1000,
      headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "secondaryUuid"},
      filterColumName: "username",
      filterOperator: "!=",
      filterValue: local!usernameToFilterOut
    ),
    refreshOnVarChange: {local!refreshCounter}
  ),
  local!backgroundAutoRefreshedTotalCount: a!refreshVariable(
    value: if(
      local!isViewingToday,
      readcsvlogpagingwithheaders(
        csvPath: "login-audit.csv", startIndex: 1, batchSize: 1,
        headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
        filterColumName: "username", filterOperator: "!=", filterValue: local!usernameToFilterOut
      ).totalCount,
      null()
    ),
    refreshInterval: 0.5
  ),
  local!isTotalCountStale: and(
    local!isViewingToday,
    local!backgroundAutoRefreshedTotalCount <> local!currentLog.totalCount
  ),

  /* using brute force a bit here to tell if any logs exist for any of the past 4 days */
  local!hasPreviousDay: or(
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-1, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-2, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-3, "yyyy-mm-dd") ).totalCount > -1,
    readcsvlog( csvPath: "login-audit.csv." & text(local!relevantDay-4, "yyyy-mm-dd") ).totalCount > -1
  ),

  local!powerBiCounter: a!refreshVariable(
    value: readcsvlogpagingwithheaders(
      csvPath: "login-audit.csv" & local!dayString,
      startIndex: 1,
      batchSize: 1,
      headers: {"timestamp", "username", "success", "ip", "mode", "agent", "uuid", "something else"},
      filterColumName: "username",
      filterOperator: "=",
      filterValue: local!usernameToFilterOut
    ).totalCount,
    refreshOnVarChange: {local!refreshCounter}
  ),

  local!splitVals: a!forEach(
    local!currentLog.rows,
    split(fv!item, ",")
  ),

  local!values: a!forEach(
    local!splitVals,

    a!localVariables(
      local!usernameAttempted: fv!item[2],  /* represents what the user typed in the login screen - causes complications... */
      local!username: lower(local!usernameAttempted),

      a!map(
        username: local!username,
        usernameAttempted: local!usernameAttempted,
        gmtTimestampString: fv!item[1],
        convertedTimeValue: local(gmt(parsedate(fv!item[1]))),  /* parseDate() is from "date and time utilities" plug-in */
        success: fv!item[3],
        ipAddress: fv!item[4],
        mode: fv!item[5]
      )
    )
  ),
  local!filteredUsernames: {"internal.service.account2", "service.account.3"},  /* list any other API / service accounts here (if any) when they may have a lot of routine hits not relevant for your use of this tool */

  local!filteredValues: a!forEach(
    local!values,
    if(contains(local!filteredUsernames, fv!item.username), {}, fv!item)
  ),

  local!usernameFilter: null(),
  local!successFilter: "either",

  local!filteringActive: or(
    a!isNotNullOrEmpty(local!usernameFilter),
    local!successFilter <> "either"
  ),

  local!reFilteredValues: if(
    not(local!filteringActive),
    local!filteredValues,

    a!flatten(a!forEach(
      local!filteredValues,
      if(
        and(
          or(
            a!isNullOrEmpty(local!usernameFilter),
            find(local!usernameFilter, fv!item.username) > 0
          ),
          or(
            local!successFilter = "either",
            and(
              local!successFilter = "success",
              fv!item.success = "Succeeded"
            ),
            and(
              local!successFilter = "fail",
              fv!item.success = "Failed"
            )
          )
        ),
        fv!item,
        {}
      )
    ))
  ),

  local!pagingInfo: a!pagingInfo(1, 20, a!sortInfo(field: "convertedTimeValue", ascending: false())),

  local!dataSubset: todatasubset(
    arrayToPage: local!reFilteredValues,
    pagingConfiguration: local!pagingInfo
  ),


  /* bending over backwards here to do the (somewhat costly) username-status-checking query (hitting query record type for user record for each line-item) just for the current page, post-filtering, etc. */
  local!updatedDataSubset: a!update(
    local!dataSubset,
    "data",
    a!forEach(
      local!dataSubset.data,
      a!update(
        data: fv!item,
        index: {"actualUsername", "usernameExists", "accountActive"},
        value: {
          a!localVariables(
            local!currentUsername: fv!item.username,
            local!currentUserStatus: rule!MIKE_UTIL_getCaseSensitiveUsernameAccountInfo(username: local!currentUsername),
            local!isActive: and(local!currentUserStatus.exists, a!defaultValue(local!currentUserStatus.isActive, false())),
            {
              local!currentUserStatus.username,
              local!currentUserStatus.exists,
              local!isActive
            }
          )
        }
      )
    )
  ),
  
  local!positiveColor: "#28724F", /* darker green */
  local!warningColor: "#E29262", /* warning color */
  local!negativeColor: "#BA0C2F", /* darker red */
  local!inactiveColor: "#bcbdc0", /* lighter grey */


  a!sectionLayout(
    label: "Login Audit Log Reader",
    contents: {
      a!columnsLayout(
        columns: {
          a!columnLayout(
            contents: {
              a!textField(
                labelPosition: "COLLAPSED",
                placeholder: "(filter by username)",
                value: local!usernameFilter,
                saveInto: local!usernameFilter,
                refreshAfter: "KEYPRESS"
              ),
              a!richTextDisplayField(
                labelPosition: "COLLAPSED",
                value: {
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "refresh",
                        caption: if(local!isTotalCountStale, "Refresh Needed (new info found)", "Refresh"),
                        link: a!dynamicLink(
                          saveInto: {
                            a!save(local!refreshCounter, local!refreshCounter + 1)
                          },
                          showWhen: local!isViewingToday
                        ),
                        linkStyle: "STANDALONE",
                        showWhen: local!isViewingToday,
                        color: if(local!isTotalCountStale, local!warningColor, "ACCENT"),
                        size: if(local!isTotalCountStale, "LARGE", null())
                      ),
                      a!richTextIcon(
                        icon: "refresh",
                        caption: "Currently viewing a previous day's log file, so 'Refresh' is not really relevant here.",
                        showWhen: not(local!isViewingToday),
                        color: local!inactiveColor,
                        size: "SMALL"
                      ),
                      a!richTextItem(
                        text: {
                          "           ",
                          a!richTextIcon(
                            icon: "remove",
                            caption: "Reset Filter(s)",
                            link: {
                              a!dynamicLink(
                                saveInto: {
                                  a!save(local!usernameFilter, ""),
                                  a!save(local!successFilter, "either"),
                                  a!save(local!pagingInfo.startIndex, 1),
                                  a!save(local!refreshCounter, local!refreshCounter + 1)
                                },
                                showWhen: local!filteringActive
                              )
                            },
                            linkStyle: "STANDALONE",
                            color: if(
                              local!filteringActive,
                              "NEGATIVE",
                              local!inactiveColor
                            ),
                            size: "MEDIUM_PLUS"
                          )
                        }
                      )
                    }
                  )
                }
              )
            },
            width: "9X"
          ),
          a!columnLayout(
            contents: {

              a!radioButtonField(
                choiceLabels: {"Success", "Failure", "Both"},
                choiceValues: {"success", "fail", "either"},
                labelPosition: "COLLAPSED",
                value: local!successFilter,
                saveInto: local!successFilter,
                choiceLayout: "COMPACT",
                marginBelow: "MORE"
              ),
              
              a!richTextDisplayField(
                labelPosition: "COLLAPSED",
                value: {
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "backward",
                        caption: "Back 1 Day",
                        color: if(
                          local!hasPreviousDay,
                          "",
                          local!inactiveColor
                        )
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, local!relevantDay - 1),
                        a!save(local!pagingInfo.startIndex, 1)
                      },
                      showWhen: local!hasPreviousDay
                    ),
                    linkStyle: "STANDALONE"
                  ),
                  "  ",
                  a!richTextItem(
                    text: text(local!relevantDay, "yyyy-mm-dd")
                  ),

                  "  ",
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "forward",
                        caption: "Forward 1 Day",
                        color: if(local!isViewingToday, local!inactiveColor, "")
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, local!relevantDay + 1),
                        a!save(local!pagingInfo.startIndex, 1)
                      },
                      showWhen: not(local!isViewingToday)
                    ),
                    linkStyle: "STANDALONE"
                  ),
                  " ",
                  a!richTextItem(
                    text: {
                      a!richTextIcon(
                        icon: "fast-forward",
                        caption: "Jump to Today"
                      )
                    },
                    link: a!dynamicLink(
                      saveInto: {
                        a!save(local!relevantDay, today()),
                        a!save(local!pagingInfo.startIndex, 1)
                      }
                    ),
                    linkStyle: "STANDALONE",
                    showWhen: local!relevantDay < today()-1
                  )
                },
                marginAbove: "LESS"
              )
            },
            width: "4X"
          )
        }
      ),

      a!gridField(
        data: local!updatedDataSubset,
        columns: {
          a!gridColumn(
            label: "Username",
            sortField: "username",
            value: a!richTextDisplayField(
              value: {
                a!richTextItem(
                  text: {
                    fv!row.username,
                    " ",
                    a!richTextIcon(
                      icon: "check-circle-o",
                      caption: "Account Active",
                      showWhen: and(fv!row.usernameExists, fv!row.accountActive),
                      color: local!positiveColor,
                      size: "SMALL"
                    ),
                    a!richTextIcon(
                      icon: "remove",
                      caption: "Account Deactivated",
                      showWhen: and(fv!row.usernameExists, not(a!defaultValue(fv!row.accountActive, true()))),
                      color: local!warningColor,
                      size: "STANDARD"
                    ),
                    a!richTextIcon(
                      icon: "question-circle-o",
                      caption: "Unknown Username",
                      showWhen: not(a!defaultValue(fv!row.usernameExists, false())),
                      color: local!negativeColor,
                      size: "SMALL"
                    )
                  }
                )
              }
            )
          ),
          a!gridColumn(
            label: "Timestamp",
            sortField: "convertedTimeValue",
            value: a!richTextDisplayField(
              value: {
                a!richTextItem(
                  text: text(fv!row.convertedTimeValue, "yyyy-mm-dd HH:mm:ss")
                ),
                char(10),
                a!richTextItem(
                  text: "(GMT:  " & fv!row.gmtTimestampString & ")",
                  color: "SECONDARY",
                  size: "SMALL",
                  style: "EMPHASIS"
                )
              }
            )
          ),
          a!gridColumn(
            label: "Success",
            value: fv!row.success
          ),
          a!gridColumn(
            label: "Mode",
            value: fv!row.mode
          ),
          a!gridColumn(
            label: "IP",
            value: fv!row.ipAddress
          )
        },
        pagingSaveInto: local!pagingInfo,
        borderStyle: "STANDARD",
        shadeAlternateRows: true()
      ),

      a!richTextDisplayField(
        label: "PowerBI hits filtered out:",
        labelPosition: "ADJACENT",
        value: local!powerBiCounter
      )
    }
  )
/* by: Mike Schmitt */
)

and here's the "MIKE_UTIL_getCaseSensitiveUsernameAccountInfo()" code:

a!localVariables(
  local!username: a!defaultValue(ri!username, "-=-=-"),
  local!userQuery: a!refreshVariable(
    refreshAlways: true(),
    value: a!queryRecordType(
      recordType: 'recordType!{SYSTEM_RECORD_TYPE_USER}User',
      pagingInfo: a!pagingInfo(1, 1),
      fetchTotalCount: true(),
      fields: {'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username', 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_active}active', 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_uuid}uuid'},
      filters: a!queryLogicalExpression(
        operator: "OR",
        filters: {
          /* doing this to rule out lowercase username first, as it's the normal way we try to initialize them */
          a!queryFilter(
            field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
            operator: "=",
            value: lower(local!username)
          ),
          a!queryFilter(
            field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
            operator: "=",
            value: local!username
          )
        },
        logicalExpressions: {
          a!queryLogicalExpression(
            operator: "AND",
    
            /* these will hopefully require the username to match exactly except for casing */
            filters: {
              a!queryFilter(
                field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
                operator: "starts with",
                value: local!username
              ),
              a!queryFilter(
                field: 'recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username',
                operator: "ends with",
                value: local!username
              )
            }
          )
        }
      )
    )
  ),
  
  if(
    local!userQuery.totalCount = 0,
    a!map(
      exists: false()
    ),
    a!map(
      exists: true(),
      username: tostring(local!userQuery.data['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_username}username']),
      uuid: local!userQuery.data[1]['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_uuid}uuid'],
      isActive: local!userQuery.data[1]['recordType!{SYSTEM_RECORD_TYPE_USER}User.fields.{SYSTEM_RECORD_TYPE_USER_FIELD_active}active']
    )
  )
)

(i'm GUESSING this should copy/paste across systems, but there's a chance you might need to manually fix the record type association)

If there are any questions or suggestions, please let me know!

  Discussion posts and replies are publicly visible